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> </div>
{{/if}} {{/if}}
{{#if (or this.dialog.message this.dialog.confirmPhrase)}} {{#if (or this.dialog.message this.dialog.bodyComponent)}}
<div class="dialog-body"> <div class="dialog-body">
{{#if this.dialog.message}} {{#if this.dialog.bodyComponent}}
<p>{{this.dialog.message}}</p> <this.dialog.bodyComponent
{{/if}} @model={{this.dialog.bodyComponentModel}}
{{#if this.dialog.confirmPhrase}}
<TextField
@value={{this.dialog.confirmPhraseInput}}
{{on "input" this.dialog.onConfirmPhraseInput}}
@id="confirm-phrase"
@autocorrect="off"
@autocapitalize="off"
/> />
{{else if this.dialog.message}}
<p>{{this.dialog.message}}</p>
{{/if}} {{/if}}
</div> </div>
{{/if}} {{/if}}

View File

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

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

View File

@ -10,7 +10,7 @@ import { findAll } from "discourse/models/login-method";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service"; 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, { export default Controller.extend(CanCheckEmails, {
dialog: service(), dialog: service(),
@ -114,29 +114,6 @@ export default Controller.extend(CanCheckEmails, {
.finally(() => this.set("resetPasswordLoading", false)); .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: { actions: {
confirmPassword() { confirmPassword() {
if (!this.password) { if (!this.password) {
@ -154,9 +131,13 @@ export default Controller.extend(CanCheckEmails, {
this.dialog.deleteConfirm({ this.dialog.deleteConfirm({
title: I18n.t("user.second_factor.disable_confirm"), 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", confirmButtonLabel: "user.second_factor.disable",
confirmPhrase: I18n.t("user.second_factor.disable"), confirmButtonDisabled: true,
confirmButtonIcon: "ban", confirmButtonIcon: "ban",
cancelButtonClass: "btn-flat", cancelButtonClass: "btn-flat",
didConfirm: () => { didConfirm: () => {

View File

@ -10,6 +10,8 @@ import {
} from "@ember/test-helpers"; } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars"; import { hbs } from "ember-cli-htmlbars";
import { query } from "discourse/tests/helpers/qunit-helpers"; 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) { module("Integration | Component | dialog-holder", function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@ -394,17 +396,41 @@ module("Integration | Component | dialog-holder", function (hooks) {
".btn-primary element is not present in the dialog" ".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 />`); await render(hbs`<DialogHolder />`);
this.dialog.deleteConfirm({ this.dialog.deleteConfirm({
message: "A delete confirm message", bodyComponent: SecondFactorConfirmPhrase,
confirmPhrase: "test", confirmButtonDisabled: true,
}); });
await settled(); await settled();
assert.strictEqual(query(".btn-danger").disabled, true); 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); 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"
);
});
}); });