DEV: Support using Ember components in dialog service ()

We often have the need to use rich HTML in dialog messages (to show lists, icons, etc.). Previously, our only option was to wrap the message in an `htmlSafe()` call. This PR adds the ability to pass a component name and model to the dialog, which means that we can write the HTML in regular Ember components. 

Example, whereas previously we would do this: 

```
    this.dialog.deleteConfirm({
      message: htmlSafe(`<li>Some text</li>`),
    });
```

instead we can now do this: 

```javascript
import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase";

...

this.dialog.deleteConfirm({
  title: I18n.t("user.second_factor.disable_confirm"),
  bodyComponent: SecondFactorConfirmPhrase,
  bodyComponentModel: model,
})
```

The model passed to the component is optional and will be available as `@model` in the Handlebars template.
This commit is contained in:
Penar Musaraj 2023-02-13 13:03:31 -05:00 committed by GitHub
parent 6e8e4430dd
commit 4072786f5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 129 additions and 70 deletions
app/assets/javascripts
dialog-holder/addon
components
services
discourse

@ -20,19 +20,14 @@
</div>
{{/if}}
{{#if (or this.dialog.message this.dialog.confirmPhrase)}}
{{#if (or this.dialog.message this.dialog.bodyComponent)}}
<div class="dialog-body">
{{#if this.dialog.message}}
<p>{{this.dialog.message}}</p>
{{/if}}
{{#if this.dialog.confirmPhrase}}
<TextField
@value={{this.dialog.confirmPhraseInput}}
{{on "input" this.dialog.onConfirmPhraseInput}}
@id="confirm-phrase"
@autocorrect="off"
@autocapitalize="off"
{{#if this.dialog.bodyComponent}}
<this.dialog.bodyComponent
@model={{this.dialog.bodyComponentModel}}
/>
{{else if this.dialog.message}}
<p>{{this.dialog.message}}</p>
{{/if}}
</div>
{{/if}}

@ -1,21 +1,21 @@
import Service from "@ember/service";
import A11yDialog from "a11y-dialog";
import { bind } from "discourse-common/utils/decorators";
import { isBlank } from "@ember/utils";
export default Service.extend({
message: null,
type: null,
dialogInstance: null,
message: null,
title: null,
titleElementId: null,
type: null,
bodyComponent: null,
bodyComponentModel: null,
confirmButtonIcon: null,
confirmButtonLabel: null,
confirmButtonClass: null,
confirmPhrase: null,
confirmPhraseInput: null,
confirmButtonDisabled: false,
cancelButtonLabel: null,
cancelButtonClass: null,
shouldDisplayCancel: null,
@ -29,15 +29,18 @@ export default Service.extend({
dialog(params) {
const {
message,
bodyComponent,
bodyComponentModel,
type,
title,
confirmButtonClass = "btn-primary",
confirmButtonIcon,
confirmButtonLabel = "ok_value",
confirmButtonClass = "btn-primary",
cancelButtonLabel = "cancel_value",
confirmButtonDisabled = false,
cancelButtonClass = "btn-default",
confirmPhrase,
cancelButtonLabel = "cancel_value",
shouldDisplayCancel,
didConfirm,
@ -45,25 +48,25 @@ export default Service.extend({
buttons,
} = params;
let confirmButtonDisabled = !isBlank(confirmPhrase);
const element = document.getElementById("dialog-holder");
this.setProperties({
message,
bodyComponent,
bodyComponentModel,
type,
dialogInstance: new A11yDialog(element),
title,
titleElementId: title !== null ? "dialog-title" : null,
confirmButtonDisabled,
confirmButtonClass,
confirmButtonLabel,
confirmButtonDisabled,
confirmButtonIcon,
confirmPhrase,
cancelButtonLabel,
confirmButtonLabel,
cancelButtonClass,
cancelButtonLabel,
shouldDisplayCancel,
didConfirm,
@ -133,19 +136,21 @@ export default Service.extend({
reset() {
this.setProperties({
message: null,
bodyComponent: null,
bodyComponentModel: null,
type: null,
dialogInstance: null,
title: null,
titleElementId: null,
confirmButtonLabel: null,
confirmButtonDisabled: false,
confirmButtonIcon: null,
cancelButtonLabel: null,
confirmButtonLabel: null,
cancelButtonClass: null,
cancelButtonLabel: null,
shouldDisplayCancel: null,
confirmPhrase: null,
confirmPhraseInput: null,
didConfirm: null,
didCancel: null,
@ -176,10 +181,7 @@ export default Service.extend({
},
@bind
onConfirmPhraseInput() {
this.set(
"confirmButtonDisabled",
this.confirmPhrase && this.confirmPhraseInput !== this.confirmPhrase
);
enableConfirmButton() {
this.set("confirmButtonDisabled", false);
},
});

@ -0,0 +1 @@
{{i18n "admin.groups.delete_with_messages_confirm" count=@model.message_count}}

@ -0,0 +1,32 @@
{{i18n "user.second_factor.delete_confirm_header"}}
<ul>
{{#each @model.totps as |totp|}}
<li>{{totp.name}}</li>
{{/each}}
{{#each @model.security_keys as |sk|}}
<li>{{sk.name}}</li>
{{/each}}
{{#if this.currentUser.second_factor_backup_enabled}}
<li>{{i18n "user.second_factor_backup.title"}}</li>
{{/if}}
</ul>
<p>
{{html-safe
(i18n
"user.second_factor.delete_confirm_instruction"
confirm=this.disabledString
)
}}
</p>
<TextField
@value={{this.confirmPhraseInput}}
{{on "input" this.onConfirmPhraseInput}}
@id="confirm-phrase"
@autocorrect="off"
@autocapitalize="off"
/>

@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import I18n from "I18n";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
export default class SecondFactorConfirmPhrase extends Component {
@service dialog;
@service currentUser;
@tracked confirmPhraseInput = "";
disabledString = I18n.t("user.second_factor.disable");
@action
onConfirmPhraseInput() {
if (this.confirmPhraseInput === this.disabledString) {
this.dialog.set("confirmButtonDisabled", false);
} else {
this.dialog.set("confirmButtonDisabled", true);
}
}
}

@ -4,6 +4,7 @@ import I18n from "I18n";
import discourseComputed from "discourse-common/utils/decorators";
import { capitalize } from "@ember/string";
import { inject as service } from "@ember/service";
import GroupDeleteDialog from "discourse/components/dialog-messages/group-delete";
const Tab = EmberObject.extend({
init() {
@ -138,17 +139,16 @@ export default Controller.extend({
const model = this.model;
const title = I18n.t("admin.groups.delete_confirm");
let message = null;
let bodyComponent = null;
if (model.has_messages && model.message_count > 0) {
message = I18n.t("admin.groups.delete_with_messages_confirm", {
count: model.message_count,
});
bodyComponent = GroupDeleteDialog;
}
this.dialog.deleteConfirm({
title,
message,
bodyComponent,
bodyComponentModel: model,
didConfirm: () => {
model
.destroy()

@ -10,7 +10,7 @@ import { findAll } from "discourse/models/login-method";
import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase";
export default Controller.extend(CanCheckEmails, {
dialog: service(),
@ -114,29 +114,6 @@ export default Controller.extend(CanCheckEmails, {
.finally(() => this.set("resetPasswordLoading", false));
},
disableAllMessage() {
let templateElements = [I18n.t("user.second_factor.delete_confirm_header")];
templateElements.push("<ul>");
this.totps.forEach((totp) => {
templateElements.push(`<li>${totp.name}</li>`);
});
this.security_keys.forEach((key) => {
templateElements.push(`<li>${key.name}</li>`);
});
if (this.currentUser.second_factor_backup_enabled) {
templateElements.push(
`<li>${I18n.t("user.second_factor_backup.title")}</li>`
);
}
templateElements.push("</ul>");
templateElements.push(
I18n.t("user.second_factor.delete_confirm_instruction", {
confirm: I18n.t("user.second_factor.disable"),
})
);
return htmlSafe(templateElements.join(""));
},
actions: {
confirmPassword() {
if (!this.password) {
@ -154,9 +131,13 @@ export default Controller.extend(CanCheckEmails, {
this.dialog.deleteConfirm({
title: I18n.t("user.second_factor.disable_confirm"),
message: this.disableAllMessage(),
bodyComponent: SecondFactorConfirmPhrase,
bodyComponentModel: {
totps: this.totps,
security_keys: this.security_keys,
},
confirmButtonLabel: "user.second_factor.disable",
confirmPhrase: I18n.t("user.second_factor.disable"),
confirmButtonDisabled: true,
confirmButtonIcon: "ban",
cancelButtonClass: "btn-flat",
didConfirm: () => {

@ -10,6 +10,8 @@ import {
} from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { query } from "discourse/tests/helpers/qunit-helpers";
import GroupDeleteDialogMessage from "discourse/components/dialog-messages/group-delete";
import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase";
module("Integration | Component | dialog-holder", function (hooks) {
setupRenderingTest(hooks);
@ -394,17 +396,41 @@ module("Integration | Component | dialog-holder", function (hooks) {
".btn-primary element is not present in the dialog"
);
});
test("delete confirm with confirmation phase", async function (assert) {
test("delete confirm with confirmation phrase component", async function (assert) {
await render(hbs`<DialogHolder />`);
this.dialog.deleteConfirm({
message: "A delete confirm message",
confirmPhrase: "test",
bodyComponent: SecondFactorConfirmPhrase,
confirmButtonDisabled: true,
});
await settled();
assert.strictEqual(query(".btn-danger").disabled, true);
await fillIn("#confirm-phrase", "test");
await fillIn("#confirm-phrase", "Disa");
assert.strictEqual(query(".btn-danger").disabled, true);
await fillIn("#confirm-phrase", "Disable");
assert.strictEqual(query(".btn-danger").disabled, false);
});
test("delete confirm with a component and model", async function (assert) {
await render(hbs`<DialogHolder />`);
const message_count = 5;
this.dialog.deleteConfirm({
bodyComponent: GroupDeleteDialogMessage,
bodyComponentModel: {
message_count,
},
});
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
I18n.t("admin.groups.delete_with_messages_confirm", {
count: message_count,
}),
"correct message is shown in dialog"
);
});
});