DEV: Convert dismiss modals to component-based API (#22262)

This PR converts the following modals:
- `dismiss-new`
- `dismiss-read`
- `dismiss-notification-confirmation`

to make use of the new component-based API

# Additional Changes
## Before
By default we display a warning modal when dismissing a notification however we bypass the warning modal for specific notification types when they are a 'low priority' type of notification (eg. likes). To do this we were overwriting `dismissWarningModal` on a given notification type component

```javascript
dismissWarningModal() {
  return null
}
```

but in the case we wanted to change the text within the modal we were calling `showModal` and then passing in the respective options all over again, putting the logic of rendering the modal in multiple places.

```javascript
dismissWarningModal() {
  const modalController = showModal("dismiss-notification-confirmation");
  modalController.set(
    "confirmationMessage",
    I18n.t("notifications.dismiss_confirmation.body.assigns", {
      count: this._unreadAssignedNotificationsCount,
    })
  );
  return modalController;
}
```
 

## After
I simplified this by adding an extensible `dismissConfirmationText` function that can be updated on a per component basis as that was the only option being overridden. 

eg

```javascript
get dismissConfirmationText() {
  return I18n.t("notifications.dismiss_confirmation.body.bookmarks", {
    count: this.#unreadBookmarkRemindersCount,
});
```

This saves us from importing the entire modal again and keeps the core logic in one place.

Instead of overwriting the `dismissWarningModal` function and returning `null` to bypass the confirmation modal, I added another extension point of `renderDismissConfirmation` (defaults to true) to _toggle_ whether we should display a confirmation when dismissing notifications.

eg

```javascript
get renderDismissConfirmation() {
  return false;
}
```

we utilize this in core for specific _low priority_ notification types. When you need the confirmation modal to be displayed no matter the case you can set `alwaysRenderDismissConfirmation` to `true`

```
get alwaysRenderDismissConfirmation(){
  return true
}
```

This can be useful when you want to render the confirmation modal on a custom notification type that is not deemed as _high priority_, leading to the confirmation modal never being rendered.

You can see this in use in [Discourse Assign](https://github.com/discourse/discourse-assign/pull/481)
This commit is contained in:
Isaac Janzen 2023-07-06 12:14:26 -05:00 committed by GitHub
parent c05e54e461
commit 8b80132f88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 242 additions and 241 deletions

View File

@ -0,0 +1,33 @@
<DModal
@closeModal={{@closeModal}}
@title={{i18n "topics.bulk.dismiss_new_modal.title"}}
>
<:body>
<p>
<PreferenceCheckbox
@labelKey="topics.bulk.dismiss_new_modal.topics"
@checked={{@model.dismissTopics}}
@class="dismiss-topics"
/>
<PreferenceCheckbox
@labelKey="topics.bulk.dismiss_new_modal.posts"
@checked={{@model.dismissPosts}}
@class="dismiss-posts"
/>
<PreferenceCheckbox
@labelKey="topics.bulk.dismiss_new_modal.untrack"
@checked={{@model.untrack}}
@class="untrack"
/>
</p>
</:body>
<:footer>
<DButton
id="dismiss-read-confirm"
@action={{this.dismissed}}
@icon="check"
@label="topics.bulk.dismiss"
class="btn-primary"
/>
</:footer>
</DModal>

View File

@ -0,0 +1,10 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
export default class DismissNew extends Component {
@action
dismissed() {
this.args.model.dismissCallback();
this.args.closeModal();
}
}

View File

@ -0,0 +1,22 @@
<DModal
@headerClass="hidden"
class="dismiss-notification-confirmation"
@closeModal={{@closeModal}}
>
<:body>
{{@model.confirmationMessage}}
</:body>
<:footer>
<DButton
@icon="check"
class="btn-primary"
@action={{this.dismiss}}
@label="notifications.dismiss_confirmation.dismiss"
/>
<DButton
@action={{@closeModal}}
@label="notifications.dismiss_confirmation.cancel"
class="btn-default"
/>
</:footer>
</DModal>

View File

@ -0,0 +1,10 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
export default class DismissNotificationConfirmation extends Component {
@action
dismiss() {
this.args.model?.dismissNotifications?.();
this.args.closeModal();
}
}

View File

@ -0,0 +1,23 @@
<DModal
@closeModal={{@closeModal}}
@title={{i18n @model.title count=@model.count}}
class="dismiss-read-modal"
>
<:body>
<p>
<PreferenceCheckbox
@labelKey="topics.bulk.also_dismiss_topics"
@checked={{this.dismissTopics}}
/>
</p>
</:body>
<:footer>
<DButton
class="btn-primary"
@action={{route-action "dismissReadTopics" this.dismissTopics}}
@icon="check"
id="dismiss-read-confirm"
@label="topics.bulk.dismiss"
/>
</:footer>
</DModal>

View File

@ -1,15 +1,17 @@
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "I18n";
import Component from "@ember/component";
import { inject as service } from "@ember/service";
import DismissReadModal from "discourse/components/modal/dismiss-read";
export default Component.extend({
tagName: "",
classNames: ["topic-dismiss-buttons"],
currentUser: service(),
modal: service(),
position: null,
selectedTopics: null,
model: null,
@ -63,13 +65,14 @@ export default Component.extend({
@action
dismissReadPosts() {
let dismissTitle = "topics.bulk.dismiss_read";
if (this.selectedTopics.length > 0) {
if (this.selectedTopics.length) {
dismissTitle = "topics.bulk.dismiss_read_with_selected";
}
showModal("dismiss-read", {
titleTranslated: I18n.t(dismissTitle, {
this.modal.show(DismissReadModal, {
model: {
title: dismissTitle,
count: this.selectedTopics.length,
}),
},
});
},
});

View File

@ -1,7 +1,6 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
import { ajax } from "discourse/lib/ajax";
import Notification from "discourse/models/notification";
import showModal from "discourse/lib/show-modal";
import I18n from "I18n";
import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item";
import UserMenuBookmarkItem from "discourse/lib/user-menu/bookmark-item";
@ -45,6 +44,12 @@ export default class UserMenuBookmarksList extends UserMenuNotificationsList {
return this.currentUser.get(key) || 0;
}
get dismissConfirmationText() {
return I18n.t("notifications.dismiss_confirmation.body.bookmarks", {
count: this.#unreadBookmarkRemindersCount,
});
}
async fetchItems() {
const data = await ajax(
`/u/${this.currentUser.username}/user-menu-bookmarks`
@ -74,15 +79,4 @@ export default class UserMenuBookmarksList extends UserMenuNotificationsList {
return content;
}
dismissWarningModal() {
const modalController = showModal("dismiss-notification-confirmation");
modalController.set(
"confirmationMessage",
I18n.t("notifications.dismiss_confirmation.body.bookmarks", {
count: this.#unreadBookmarkRemindersCount,
})
);
return modalController;
}
}

View File

@ -28,6 +28,10 @@ export default class UserMenuItemsList extends Component {
return "user-menu/items-list-empty-state";
}
get renderDismissConfirmation() {
return false;
}
async fetchItems() {
throw new Error(
`the fetchItems method must be implemented in ${this.constructor.name}`
@ -38,10 +42,6 @@ export default class UserMenuItemsList extends Component {
await this.#load();
}
dismissWarningModal() {
return null;
}
async #load() {
const cached = this.#getCachedItems();
if (cached?.length) {

View File

@ -5,8 +5,8 @@ export default class UserMenuLikesNotificationsList extends UserMenuNotification
return this.filterByTypes;
}
dismissWarningModal() {
return null;
get renderDismissConfirmation() {
return false;
}
get emptyStateComponent() {

View File

@ -1,7 +1,6 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
import { ajax } from "discourse/lib/ajax";
import Notification from "discourse/models/notification";
import showModal from "discourse/lib/show-modal";
import I18n from "I18n";
import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item";
import UserMenuMessageItem from "discourse/lib/user-menu/message-item";
@ -49,6 +48,12 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList {
return this.currentUser.get(key) || 0;
}
get dismissConfirmationText() {
return I18n.t("notifications.dismiss_confirmation.body.messages", {
count: this.#unreadMessagesNotifications,
});
}
async fetchItems() {
const data = await ajax(
`/u/${this.currentUser.username}/user-menu-private-messages`
@ -97,15 +102,4 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList {
return content;
}
dismissWarningModal() {
const modalController = showModal("dismiss-notification-confirmation");
modalController.set(
"confirmationMessage",
I18n.t("notifications.dismiss_confirmation.body.messages", {
count: this.#unreadMessagesNotifications,
})
);
return modalController;
}
}

View File

@ -6,12 +6,12 @@ import {
mergeSortedLists,
postRNWebviewMessage,
} from "discourse/lib/utilities";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item";
import Notification from "discourse/models/notification";
import UserMenuReviewable from "discourse/models/user-menu-reviewable";
import UserMenuReviewableItem from "discourse/lib/user-menu/reviewable-item";
import DismissNotificationConfirmationModal from "discourse/components/modal/dismiss-notification-confirmation";
export default class UserMenuNotificationsList extends UserMenuItemsList {
@service appEvents;
@ -19,6 +19,7 @@ export default class UserMenuNotificationsList extends UserMenuItemsList {
@service siteSettings;
@service site;
@service store;
@service modal;
get filterByTypes() {
return this.args.filterByTypes;
@ -65,6 +66,20 @@ export default class UserMenuNotificationsList extends UserMenuItemsList {
}
}
get renderDismissConfirmation() {
return true;
}
get dismissConfirmationText() {
return I18n.t("notifications.dismiss_confirmation.body.default", {
count: this.currentUser.unread_high_priority_notifications,
});
}
get alwaysRenderDismissConfirmation() {
return false;
}
async fetchItems() {
const params = {
limit: 30,
@ -138,56 +153,64 @@ export default class UserMenuNotificationsList extends UserMenuItemsList {
return content;
}
dismissWarningModal() {
if (this.currentUser.unread_high_priority_notifications > 0) {
const modalController = showModal("dismiss-notification-confirmation");
modalController.set(
"confirmationMessage",
I18n.t("notifications.dismiss_confirmation.body.default", {
count: this.currentUser.unread_high_priority_notifications,
})
);
return modalController;
}
}
@action
dismissButtonClick() {
async performDismiss() {
const opts = { type: "PUT" };
const dismissTypes = this.dismissTypes;
if (dismissTypes?.length > 0) {
opts.data = { dismiss_types: dismissTypes.join(",") };
}
const modalController = this.dismissWarningModal();
const modalCallback = () => {
ajax("/notifications/mark-read", opts).then(() => {
if (dismissTypes) {
const unreadNotificationCountsHash = {
...this.currentUser.grouped_unread_notifications,
};
dismissTypes.forEach((type) => {
const typeId = this.site.notification_types[type];
if (typeId) {
delete unreadNotificationCountsHash[typeId];
}
});
this.currentUser.set(
"grouped_unread_notifications",
unreadNotificationCountsHash
);
} else {
this.currentUser.set("all_unread_notifications_count", 0);
this.currentUser.set("unread_high_priority_notifications", 0);
this.currentUser.set("grouped_unread_notifications", {});
await ajax("/notifications/mark-read", opts);
if (dismissTypes) {
const unreadNotificationCountsHash = {
...this.currentUser.grouped_unread_notifications,
};
dismissTypes.forEach((type) => {
const typeId = this.site.notification_types[type];
if (typeId) {
delete unreadNotificationCountsHash[typeId];
}
this.refreshList();
postRNWebviewMessage("markRead", "1");
});
};
if (modalController) {
modalController.set("dismissNotifications", modalCallback);
this.currentUser.set(
"grouped_unread_notifications",
unreadNotificationCountsHash
);
} else {
modalCallback();
this.currentUser.set("all_unread_notifications_count", 0);
this.currentUser.set("unread_high_priority_notifications", 0);
this.currentUser.set("grouped_unread_notifications", {});
}
this.refreshList();
postRNWebviewMessage("markRead", "1");
}
dismissWarningModal() {
this.modal.show(DismissNotificationConfirmationModal, {
model: {
confirmationMessage: this.dismissConfirmationText,
dismissNotifications: () => this.performDismiss(),
},
});
}
@action
dismissButtonClick() {
// by default we display a warning modal when dismissing a notification
// however we bypass the warning modal for specific notification types when
// they are a 'low priority' type of notification (eg. likes)
if (
this.renderDismissConfirmation ||
this.alwaysRenderDismissConfirmation
) {
if (
this.currentUser.unread_high_priority_notifications > 0 ||
this.alwaysRenderDismissConfirmation
) {
this.dismissWarningModal();
} else {
this.performDismiss();
}
} else {
this.performDismiss();
}
}
}

View File

@ -9,7 +9,7 @@ export default class UserMenuOtherNotificationsList extends UserMenuNotification
return "user-menu/other-notifications-list-empty-state";
}
dismissWarningModal() {
return null;
get renderDismissConfirmation() {
return false;
}
}

View File

@ -5,8 +5,8 @@ export default class UserMenuRepliesNotificationsList extends UserMenuNotificati
return this.filterByTypes;
}
dismissWarningModal() {
return null;
get renderDismissConfirmation() {
return false;
}
get emptyStateComponent() {

View File

@ -1,13 +0,0 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object";
export default class DismissNewController extends Controller.extend(
ModalFunctionality
) {
@action
dismiss() {
this.dismissCallback();
this.send("closeModal");
}
}

View File

@ -1,11 +0,0 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Controller.extend(ModalFunctionality, {
actions: {
dismiss() {
this.send("closeModal");
this.dismissNotifications();
},
},
});

View File

@ -3,11 +3,13 @@ import getURL from "discourse-common/lib/get-url";
import { iconHTML } from "discourse-common/lib/icon-library";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import { ajax } from "discourse/lib/ajax";
import showModal from "discourse/lib/show-modal";
import I18n from "I18n";
import { htmlSafe } from "@ember/template";
import { inject as service } from "@ember/service";
import DismissNotificationConfirmationModal from "discourse/components/modal/dismiss-notification-confirmation";
export default Controller.extend({
modal: service(),
application: controller(),
queryParams: ["filter"],
filter: "all",
@ -49,27 +51,24 @@ export default Controller.extend({
);
},
markRead() {
return ajax("/notifications/mark-read", { type: "PUT" }).then(() => {
this.model.forEach((n) => n.set("read", true));
});
async markRead() {
await ajax("/notifications/mark-read", { type: "PUT" });
this.model.forEach((n) => n.set("read", true));
},
actions: {
async resetNew() {
const unreadHighPriorityNotifications = this.currentUser.get(
"unread_high_priority_notifications"
);
if (unreadHighPriorityNotifications > 0) {
showModal("dismiss-notification-confirmation").setProperties({
confirmationMessage: I18n.t(
"notifications.dismiss_confirmation.body.default",
{
count: unreadHighPriorityNotifications,
}
),
dismissNotifications: () => this.markRead(),
if (this.currentUser.unread_high_priority_notifications > 0) {
this.modal.show(DismissNotificationConfirmationModal, {
model: {
confirmationMessage: I18n.t(
"notifications.dismiss_confirmation.body.default",
{
count: this.currentUser.unread_high_priority_notifications,
}
),
dismissNotifications: () => this.markRead(),
},
});
} else {
this.markRead();

View File

@ -1,30 +1,29 @@
import Mixin from "@ember/object/mixin";
import User from "discourse/models/user";
import showModal from "discourse/lib/show-modal";
import I18n from "I18n";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import DismissNewModal from "discourse/components/modal/dismiss-new";
export default Mixin.create({
actions: {
resetNew() {
const user = User.current();
if (!user.new_new_view_enabled) {
return this.callResetNew();
}
const controller = showModal("dismiss-new", {
model: {
dismissTopics: true,
dismissPosts: true,
},
titleTranslated: I18n.t("topics.bulk.dismiss_new_modal.title"),
});
modal: service(),
controller.set("dismissCallback", () => {
this.callResetNew(
controller.model.dismissPosts,
controller.model.dismissTopics,
controller.model.untrack
);
});
},
@action
resetNew() {
const user = User.current();
if (!user.new_new_view_enabled) {
return this.callResetNew();
}
this.modal.show(DismissNewModal, {
model: {
dismissTopics: true,
dismissPosts: true,
dismissCallback: () =>
this.callResetNew(
this.model.dismissPosts,
this.model.dismissTopics,
this.model.untrack
),
},
});
},
});

View File

@ -1,29 +0,0 @@
<DModalBody>
<p>
<PreferenceCheckbox
@labelKey="topics.bulk.dismiss_new_modal.topics"
@checked={{this.model.dismissTopics}}
@class="dismiss-topics"
/>
<PreferenceCheckbox
@labelKey="topics.bulk.dismiss_new_modal.posts"
@checked={{this.model.dismissPosts}}
@class="dismiss-posts"
/>
<PreferenceCheckbox
@labelKey="topics.bulk.dismiss_new_modal.untrack"
@checked={{this.model.untrack}}
@class="untrack"
/>
</p>
</DModalBody>
<div class="modal-footer">
<DButton
@class="btn-primary"
@action="dismiss"
@icon="check"
@id="dismiss-read-confirm"
@label="topics.bulk.dismiss"
/>
</div>

View File

@ -1,17 +0,0 @@
<DModalBody @headerClass="hidden" @class="dismiss-notification-confirmation">
{{this.confirmationMessage}}
</DModalBody>
<div class="modal-footer">
<DButton
@icon="check"
@class="btn-primary"
@action={{action "dismiss"}}
@label="notifications.dismiss_confirmation.dismiss"
/>
<DButton
@action={{route-action "closeModal"}}
@label="notifications.dismiss_confirmation.cancel"
@class="btn-default"
/>
</div>

View File

@ -1,18 +0,0 @@
<DModalBody>
<p>
<PreferenceCheckbox
@labelKey="topics.bulk.also_dismiss_topics"
@checked={{this.dismissTopics}}
/>
</p>
</DModalBody>
<div class="modal-footer">
<DButton
@class="btn-primary"
@action={{route-action "dismissReadTopics" this.dismissTopics}}
@icon="check"
@id="dismiss-read-confirm"
@label="topics.bulk.dismiss"
/>
</div>

View File

@ -882,7 +882,9 @@ acceptance("User menu - Dismiss button", function (needs) {
await click(".user-menu .notifications-dismiss");
assert.strictEqual(
query(".dismiss-notification-confirmation").textContent.trim(),
query(
".dismiss-notification-confirmation .modal-body"
).textContent.trim(),
I18n.t("notifications.dismiss_confirmation.body.default", { count: 10 }),
"confirmation modal is shown when there are unread high pri notifications"
);
@ -918,7 +920,9 @@ acceptance("User menu - Dismiss button", function (needs) {
await click(".user-menu .notifications-dismiss");
assert.strictEqual(
query(".dismiss-notification-confirmation").textContent.trim(),
query(
".dismiss-notification-confirmation .modal-body"
).textContent.trim(),
I18n.t("notifications.dismiss_confirmation.body.bookmarks", {
count: 103,
}),
@ -972,7 +976,9 @@ acceptance("User menu - Dismiss button", function (needs) {
await click(".user-menu .notifications-dismiss");
assert.strictEqual(
query(".dismiss-notification-confirmation").textContent.trim(),
query(
".dismiss-notification-confirmation .modal-body"
).textContent.trim(),
I18n.t("notifications.dismiss_confirmation.body.messages", {
count: 89,
}),

View File

@ -3,9 +3,7 @@ import { setupTest } from "ember-qunit";
import sinon from "sinon";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
import EmberObject from "@ember/object";
import * as showModal from "discourse/lib/show-modal";
import User from "discourse/models/user";
import I18n from "I18n";
module("Unit | Controller | user-notifications", function (hooks) {
setupTest(hooks);
@ -58,29 +56,4 @@ module("Unit | Controller | user-notifications", function (hooks) {
assert.strictEqual(markRead, true);
});
test("Shows modal when has high priority notifications", function (assert) {
let capturedProperties;
sinon
.stub(showModal, "default")
.withArgs("dismiss-notification-confirmation")
.returns({
setProperties: (properties) => (capturedProperties = properties),
});
const currentUser = User.create({ unread_high_priority_notifications: 1 });
const controller = this.owner.lookup("controller:user-notifications");
controller.setProperties({ currentUser });
const markReadFake = sinon.fake();
sinon.stub(controller, "markRead").callsFake(markReadFake);
controller.send("resetNew");
assert.strictEqual(
capturedProperties.confirmationMessage,
I18n.t("notifications.dismiss_confirmation.body.default", { count: 1 })
);
capturedProperties.dismissNotifications();
assert.strictEqual(markReadFake.callCount, 1);
});
});