DEV: Support using Ember components in dialog service (#20230)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: () => {

View File

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