DEV: Add A11Y-friendly dialog addon (#18028)

This adds a new framework for accessible dialogs that will eventually replace bootbox. Under the hood, it uses the a11y-dialog package and an in-repo Ember addon. See PR for usage details.
This commit is contained in:
Penar Musaraj
2022-08-29 13:59:57 -04:00
committed by GitHub
parent c3a93597c1
commit 4116bce902
26 changed files with 4547 additions and 152 deletions

View File

@ -1,11 +1,15 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import { empty } from "@ember/object/computed"; import { empty } from "@ember/object/computed";
import { observes } from "discourse-common/utils/decorators"; import { observes } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { escapeExpression } from "discourse/lib/utilities";
export default Controller.extend({ export default Controller.extend({
dialog: service(),
/** /**
Is the "send test email" button disabled? Is the "send test email" button disabled?
@ -44,13 +48,17 @@ export default Controller.extend({
) )
.catch((e) => { .catch((e) => {
if (e.jqXHR.responseJSON?.errors) { if (e.jqXHR.responseJSON?.errors) {
bootbox.alert( this.dialog.alert({
message: htmlSafe(
I18n.t("admin.email.error", { I18n.t("admin.email.error", {
server_error: e.jqXHR.responseJSON.errors[0], server_error: escapeExpression(
e.jqXHR.responseJSON.errors[0]
),
}) })
); ),
});
} else { } else {
bootbox.alert(I18n.t("admin.email.test_error")); this.dialog.alert({ message: I18n.t("admin.email.test_error") });
} }
}) })
.finally(() => this.set("sendingEmail", false)); .finally(() => this.set("sendingEmail", false));

View File

@ -2,12 +2,13 @@ import EmberObject, { action, computed } from "@ember/object";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import { sort } from "@ember/object/computed"; import { sort } from "@ember/object/computed";
import { inject as service } from "@ember/service";
const ALL_FILTER = "all"; const ALL_FILTER = "all";
export default Controller.extend({ export default Controller.extend({
dialog: service(),
filter: null, filter: null,
sorting: null, sorting: null,
@ -72,19 +73,17 @@ export default Controller.extend({
@action @action
destroyEmoji(emoji) { destroyEmoji(emoji) {
return bootbox.confirm( this.dialog.yesNoConfirm({
I18n.t("admin.emoji.delete_confirm", { name: emoji.get("name") }), message: I18n.t("admin.emoji.delete_confirm", {
I18n.t("no_value"), name: emoji.get("name"),
I18n.t("yes_value"), }),
(destroy) => { didConfirm: () => {
if (destroy) {
return ajax("/admin/customize/emojis/" + emoji.get("name"), { return ajax("/admin/customize/emojis/" + emoji.get("name"), {
type: "DELETE", type: "DELETE",
}).then(() => { }).then(() => {
this.model.removeObject(emoji); this.model.removeObject(emoji);
}); });
} },
} });
);
}, },
}); });

View File

@ -1,11 +1,13 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import I18n from "I18n"; import I18n from "I18n";
import bootbox from "bootbox";
import { bufferedProperty } from "discourse/mixins/buffered-content"; import { bufferedProperty } from "discourse/mixins/buffered-content";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default Controller.extend(bufferedProperty("siteText"), { export default Controller.extend(bufferedProperty("siteText"), {
dialog: service(),
saved: false, saved: false,
queryParams: ["locale"], queryParams: ["locale"],
@ -14,7 +16,7 @@ export default Controller.extend(bufferedProperty("siteText"), {
return this.siteText.value === value; return this.siteText.value === value;
}, },
actions: { @action
saveChanges() { saveChanges() {
const attrs = this.buffered.getProperties("value"); const attrs = this.buffered.getProperties("value");
attrs.locale = this.locale; attrs.locale = this.locale;
@ -28,11 +30,13 @@ export default Controller.extend(bufferedProperty("siteText"), {
.catch(popupAjaxError); .catch(popupAjaxError);
}, },
@action
revertChanges() { revertChanges() {
this.set("saved", false); this.set("saved", false);
bootbox.confirm(I18n.t("admin.site_text.revert_confirm"), (result) => { this.dialog.yesNoConfirm({
if (result) { message: I18n.t("admin.site_text.revert_confirm"),
didConfirm: () => {
this.siteText this.siteText
.revert(this.locale) .revert(this.locale)
.then((props) => { .then((props) => {
@ -41,8 +45,7 @@ export default Controller.extend(bufferedProperty("siteText"), {
this.commitBuffer(); this.commitBuffer();
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
} },
}); });
}, },
},
}); });

View File

@ -6,17 +6,16 @@ import CanCheckEmails from "discourse/mixins/can-check-emails";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { iconHTML } from "discourse-common/lib/icon-library";
import { extractError, popupAjaxError } from "discourse/lib/ajax-error"; import { extractError, popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
export default Controller.extend(CanCheckEmails, { export default Controller.extend(CanCheckEmails, {
router: service(), router: service(),
dialog: service(),
adminTools: service(), adminTools: service(),
originalPrimaryGroupId: null, originalPrimaryGroupId: null,
customGroupIdsBuffer: null, customGroupIdsBuffer: null,
@ -130,7 +129,7 @@ export default Controller.extend(CanCheckEmails, {
groupAdded(added) { groupAdded(added) {
this.model this.model
.groupAdded(added) .groupAdded(added)
.catch(() => bootbox.alert(I18n.t("generic_error"))); .catch(() => this.dialog.alert(I18n.t("generic_error")));
}, },
groupRemoved(groupId) { groupRemoved(groupId) {
@ -141,7 +140,7 @@ export default Controller.extend(CanCheckEmails, {
this.set("originalPrimaryGroupId", null); this.set("originalPrimaryGroupId", null);
} }
}) })
.catch(() => bootbox.alert(I18n.t("generic_error"))); .catch(() => this.dialog.alert(I18n.t("generic_error")));
}, },
@discourseComputed("ssoLastPayload") @discourseComputed("ssoLastPayload")
@ -156,16 +155,16 @@ export default Controller.extend(CanCheckEmails, {
.then(() => DiscourseURL.redirectTo("/")) .then(() => DiscourseURL.redirectTo("/"))
.catch((e) => { .catch((e) => {
if (e.status === 404) { if (e.status === 404) {
bootbox.alert(I18n.t("admin.impersonate.not_found")); this.dialog.alert(I18n.t("admin.impersonate.not_found"));
} else { } else {
bootbox.alert(I18n.t("admin.impersonate.invalid")); this.dialog.alert(I18n.t("admin.impersonate.invalid"));
} }
}); });
}, },
logOut() { logOut() {
return this.model return this.model
.logOut() .logOut()
.then(() => bootbox.alert(I18n.t("admin.user.logged_out"))); .then(() => this.dialog.alert(I18n.t("admin.user.logged_out")));
}, },
resetBounceScore() { resetBounceScore() {
return this.model.resetBounceScore(); return this.model.resetBounceScore();
@ -188,13 +187,15 @@ export default Controller.extend(CanCheckEmails, {
const error = I18n.t("admin.user.deactivate_failed", { const error = I18n.t("admin.user.deactivate_failed", {
error: this._formatError(e), error: this._formatError(e),
}); });
bootbox.alert(error); this.dialog.alert(error);
}); });
}, },
sendActivationEmail() { sendActivationEmail() {
return this.model return this.model
.sendActivationEmail() .sendActivationEmail()
.then(() => bootbox.alert(I18n.t("admin.user.activation_email_sent"))) .then(() =>
this.dialog.alert(I18n.t("admin.user.activation_email_sent"))
)
.catch(popupAjaxError); .catch(popupAjaxError);
}, },
activate() { activate() {
@ -210,7 +211,7 @@ export default Controller.extend(CanCheckEmails, {
const error = I18n.t("admin.user.activate_failed", { const error = I18n.t("admin.user.activate_failed", {
error: this._formatError(e), error: this._formatError(e),
}); });
bootbox.alert(error); this.dialog.alert(error);
}); });
}, },
revokeAdmin() { revokeAdmin() {
@ -221,7 +222,7 @@ export default Controller.extend(CanCheckEmails, {
.grantAdmin() .grantAdmin()
.then((result) => { .then((result) => {
if (result.email_confirmation_required) { if (result.email_confirmation_required) {
bootbox.alert(I18n.t("admin.user.grant_admin_confirm")); this.dialog.alert(I18n.t("admin.user.grant_admin_confirm"));
} }
}) })
.catch((error) => { .catch((error) => {
@ -255,7 +256,7 @@ export default Controller.extend(CanCheckEmails, {
I18n.t("admin.user.trust_level_change_failed", { I18n.t("admin.user.trust_level_change_failed", {
error: this._formatError(e), error: this._formatError(e),
}); });
bootbox.alert(error); this.dialog.alert(error);
}); });
}, },
restoreTrustLevel() { restoreTrustLevel() {
@ -275,7 +276,7 @@ export default Controller.extend(CanCheckEmails, {
I18n.t("admin.user.trust_level_change_failed", { I18n.t("admin.user.trust_level_change_failed", {
error: this._formatError(e), error: this._formatError(e),
}); });
bootbox.alert(error); this.dialog.alert(error);
}); });
}, },
unsilence() { unsilence() {
@ -287,7 +288,6 @@ export default Controller.extend(CanCheckEmails, {
anonymize() { anonymize() {
const user = this.model; const user = this.model;
const message = I18n.t("admin.user.anonymize_confirm");
const performAnonymize = () => { const performAnonymize = () => {
this.model this.model
@ -302,31 +302,32 @@ export default Controller.extend(CanCheckEmails, {
document.location = getURL("/admin/users/list/active"); document.location = getURL("/admin/users/list/active");
} }
} else { } else {
bootbox.alert(I18n.t("admin.user.anonymize_failed")); this.dialog.alert(I18n.t("admin.user.anonymize_failed"));
if (data.user) { if (data.user) {
user.setProperties(data.user); user.setProperties(data.user);
} }
} }
}) })
.catch(() => bootbox.alert(I18n.t("admin.user.anonymize_failed"))); .catch(() =>
this.dialog.alert(I18n.t("admin.user.anonymize_failed"))
);
}; };
const buttons = [
this.dialog.alert({
message: I18n.t("admin.user.anonymize_confirm"),
class: "delete-user-modal",
buttons: [
{
icon: "exclamation-triangle",
label: I18n.t("admin.user.anonymize_yes"),
class: "btn-danger",
action: () => performAnonymize(),
},
{ {
label: I18n.t("composer.cancel"), label: I18n.t("composer.cancel"),
class: "cancel",
link: true,
}, },
{ ],
label: I18n.t("admin.user.anonymize_yes"), });
class: "btn btn-danger",
icon: iconHTML("exclamation-triangle"),
callback: () => {
performAnonymize();
},
},
];
bootbox.dialog(message, buttons, { classes: "delete-user-modal" });
}, },
disableSecondFactor() { disableSecondFactor() {
@ -345,11 +346,10 @@ export default Controller.extend(CanCheckEmails, {
destroy() { destroy() {
const postCount = this.get("model.post_count"); const postCount = this.get("model.post_count");
const maxPostCount = this.siteSettings.delete_all_posts_max; const maxPostCount = this.siteSettings.delete_all_posts_max;
const message = I18n.t("admin.user.delete_confirm");
const location = document.location.pathname; const location = document.location.pathname;
const performDestroy = (block) => { const performDestroy = (block) => {
bootbox.dialog(I18n.t("admin.user.deleting_user")); this.dialog.notice(I18n.t("admin.user.deleting_user"));
let formData = { context: location }; let formData = { context: location };
if (block) { if (block) {
formData["block_email"] = true; formData["block_email"] = true;
@ -369,38 +369,38 @@ export default Controller.extend(CanCheckEmails, {
document.location = getURL("/admin/users/list/active"); document.location = getURL("/admin/users/list/active");
} }
} else { } else {
bootbox.alert(I18n.t("admin.user.delete_failed")); this.dialog.alert(I18n.t("admin.user.delete_failed"));
} }
}) })
.catch(() => { .catch(() => {
bootbox.alert(I18n.t("admin.user.delete_failed")); this.dialog.alert(I18n.t("admin.user.delete_failed"));
}); });
}; };
const buttons = [ this.dialog.alert({
{ message: I18n.t("admin.user.delete_confirm"),
label: I18n.t("composer.cancel"), class: "delete-user-modal",
class: "btn", buttons: [
link: true,
},
{
icon: iconHTML("exclamation-triangle"),
label: I18n.t("admin.user.delete_and_block"),
class: "btn btn-danger",
callback: () => {
performDestroy(true);
},
},
{ {
label: I18n.t("admin.user.delete_dont_block"), label: I18n.t("admin.user.delete_dont_block"),
class: "btn btn-primary", class: "btn-primary",
callback: () => { action: () => {
performDestroy(false); return performDestroy(true);
}, },
}, },
]; {
icon: "exclamation-triangle",
bootbox.dialog(message, buttons, { classes: "delete-user-modal" }); label: I18n.t("admin.user.delete_and_block"),
class: "btn-danger",
action: () => {
return performDestroy(false);
},
},
{
label: I18n.t("composer.cancel"),
},
],
});
}, },
promptTargetUser() { promptTargetUser() {
@ -439,12 +439,12 @@ export default Controller.extend(CanCheckEmails, {
model: this.model, model: this.model,
}); });
} else { } else {
bootbox.alert(I18n.t("admin.user.merge_failed")); this.dialog.alert(I18n.t("admin.user.merge_failed"));
} }
}) })
.catch(() => { .catch(() => {
AdminUser.find(user.id).then((u) => user.setProperties(u)); AdminUser.find(user.id).then((u) => user.setProperties(u));
bootbox.alert(I18n.t("admin.user.merge_failed")); this.dialog.alert(I18n.t("admin.user.merge_failed"));
}); });
}, },
@ -532,7 +532,7 @@ export default Controller.extend(CanCheckEmails, {
data: { primary_group_id: primaryGroupId }, data: { primary_group_id: primaryGroupId },
}) })
.then(() => this.set("originalPrimaryGroupId", primaryGroupId)) .then(() => this.set("originalPrimaryGroupId", primaryGroupId))
.catch(() => bootbox.alert(I18n.t("generic_error"))); .catch(() => this.dialog.alert(I18n.t("generic_error")));
}, },
resetPrimaryGroup() { resetPrimaryGroup() {
@ -540,16 +540,10 @@ export default Controller.extend(CanCheckEmails, {
}, },
deleteSSORecord() { deleteSSORecord() {
return bootbox.confirm( return this.dialog.yesNoConfirm({
I18n.t("admin.user.discourse_connect.confirm_delete"), message: I18n.t("admin.user.discourse_connect.confirm_delete"),
I18n.t("no_value"), didConfirm: () => this.model.deleteSSORecord(),
I18n.t("yes_value"), });
(confirmed) => {
if (confirmed) {
return this.model.deleteSSORecord();
}
}
);
}, },
checkSsoEmail() { checkSsoEmail() {
@ -607,7 +601,7 @@ export default Controller.extend(CanCheckEmails, {
let error; let error;
AdminUser.find(user.get("id")).then((u) => user.setProperties(u)); AdminUser.find(user.get("id")).then((u) => user.setProperties(u));
error = extractError(e) || I18n.t("admin.user.delete_posts_failed"); error = extractError(e) || I18n.t("admin.user.delete_posts_failed");
bootbox.alert(error); this.dialog.alert(error);
}); });
}; };

View File

@ -1,7 +1,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import I18n from "I18n"; import I18n from "I18n";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import bootbox from "bootbox";
import logout from "discourse/lib/logout"; import logout from "discourse/lib/logout";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { setLogoffCallback } from "discourse/lib/ajax"; import { setLogoffCallback } from "discourse/lib/ajax";
@ -14,6 +13,7 @@ export function addPluginDocumentTitleCounter(counterFunction) {
export default Component.extend({ export default Component.extend({
tagName: "", tagName: "",
documentTitle: service(), documentTitle: service(),
dialog: service(),
_showingLogout: false, _showingLogout: false,
didInsertElement() { didInsertElement() {
@ -74,13 +74,12 @@ export default Component.extend({
this._showingLogout = true; this._showingLogout = true;
this.messageBus.stop(); this.messageBus.stop();
bootbox.dialog(
I18n.t("logout"), this.dialog.alert({
{ label: I18n.t("refresh"), callback: logout }, message: I18n.t("logout"),
{ confirmButtonLabel: "refresh",
onEscape: () => logout(), didConfirm: () => logout(),
backdrop: "static", didCancel: () => logout(),
} });
);
}, },
}); });

View File

@ -1,11 +1,12 @@
import Controller, { inject as controller } from "@ember/controller"; import Controller, { inject as controller } from "@ember/controller";
import I18n from "I18n"; import I18n from "I18n";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import bootbox from "bootbox";
import { exportUserArchive } from "discourse/lib/export-csv"; import { exportUserArchive } from "discourse/lib/export-csv";
import { inject as service } from "@ember/service";
import discourseComputed, { observes } from "discourse-common/utils/decorators"; import discourseComputed, { observes } from "discourse-common/utils/decorators";
export default Controller.extend({ export default Controller.extend({
dialog: service(),
application: controller(), application: controller(),
user: controller(), user: controller(),
userActionType: null, userActionType: null,
@ -44,12 +45,10 @@ export default Controller.extend({
actions: { actions: {
exportUserArchive() { exportUserArchive() {
bootbox.confirm( this.dialog.yesNoConfirm({
I18n.t("user.download_archive.confirm"), message: I18n.t("user.download_archive.confirm"),
I18n.t("no_value"), didConfirm: () => exportUserArchive(),
I18n.t("yes_value"), });
(confirmed) => (confirmed ? exportUserArchive() : null)
);
}, },
}, },
}); });

View File

@ -1,7 +1,7 @@
import I18n from "I18n"; import I18n from "I18n";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { getOwner } from "discourse-common/lib/get-owner";
function exportEntityByType(type, entity, args) { function exportEntityByType(type, entity, args) {
return ajax("/export_csv/export_entity.json", { return ajax("/export_csv/export_entity.json", {
@ -11,9 +11,10 @@ function exportEntityByType(type, entity, args) {
} }
export function exportUserArchive() { export function exportUserArchive() {
const dialog = getOwner(this).lookup("service:dialog");
return exportEntityByType("user", "user_archive") return exportEntityByType("user", "user_archive")
.then(function () { .then(function () {
bootbox.alert(I18n.t("user.download_archive.success")); dialog.alert(I18n.t("user.download_archive.success"));
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
} }

View File

@ -57,6 +57,7 @@
<PluginOutlet @name="below-footer" @connectorTagName="div" @args={{hash showFooter=this.showFooter}} /> <PluginOutlet @name="below-footer" @connectorTagName="div" @args={{hash showFooter=this.showFooter}} />
{{outlet "modal"}} {{outlet "modal"}}
<DialogHolder />
<TopicEntrance /> <TopicEntrance />
{{outlet "composer"}} {{outlet "composer"}}

View File

@ -0,0 +1,33 @@
<div id="dialog-holder" class="dialog-container {{this.dialog.class}}" aria-labelledby={{this.dialog.titleElementId}} aria-hidden="true">
<div class="dialog-overlay" data-a11y-dialog-hide></div>
{{#if this.dialog.type}}
<div class="dialog-content" role="document">
{{#if this.dialog.title}}
<div class="dialog-header">
<h3 id={{this.dialog.titleElementId}}>{{this.dialog.title}}</h3>
<DButton @icon="times" @action={{action this.dialog.cancel}} @class="btn-flat dialog-close close" @title="modal.close" />
</div>
{{/if}}
{{#if this.dialog.message}}
<div class="dialog-body">
{{this.dialog.message}}
</div>
{{/if}}
{{#if (notEq this.dialog.type "notice")}}
<div class="dialog-footer">
{{#each this.dialog.buttons as |button|}}
<DButton @icon={{button.icon}} @class={{button.class}} @action={{action "handleButtonAction" button}} @translatedLabel={{button.label}} />
{{else}}
<DButton @class="btn-primary" @action={{this.dialog.didConfirmWrapped}} @icon={{this.dialog.confirmButtonIcon}} @label={{this.dialog.confirmButtonLabel}} />
{{#if this.dialog.shouldDisplayCancel}}
<DButton @class="btn-default" @action={{this.dialog.cancel}} @label={{this.dialog.cancelButtonLabel}} />
{{/if}}
{{/each}}
</div>
{{/if}}
</div>
{{/if}}
</div>

View File

@ -0,0 +1,16 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
export default class DialogHolder extends Component {
@service dialog;
@action
async handleButtonAction(btn) {
if (btn.action && typeof btn.action === "function") {
await btn.action();
}
this.dialog.cancel();
}
}

View File

@ -0,0 +1,152 @@
import Service from "@ember/service";
import A11yDialog from "a11y-dialog";
import { bind } from "discourse-common/utils/decorators";
export default Service.extend({
message: null,
type: null,
dialogInstance: null,
title: null,
titleElementId: null,
confirmButtonIcon: null,
confirmButtonLabel: null,
cancelButtonLabel: null,
shouldDisplayCancel: null,
didConfirm: null,
didCancel: null,
buttons: null,
class: null,
_confirming: false,
dialog(params) {
const {
message,
type,
title,
confirmButtonIcon,
confirmButtonLabel = "ok_value",
cancelButtonLabel = "cancel_value",
shouldDisplayCancel,
didConfirm,
didCancel,
buttons,
} = params;
const element = document.getElementById("dialog-holder");
this.setProperties({
message,
type,
dialogInstance: new A11yDialog(element),
title,
titleElementId: title !== null ? "dialog-title" : null,
confirmButtonLabel,
confirmButtonIcon,
cancelButtonLabel,
shouldDisplayCancel,
didConfirm,
didCancel,
buttons,
class: params.class,
});
this.dialogInstance.show();
this.dialogInstance.on("hide", () => {
if (!this._confirming && this.didCancel) {
this.didCancel();
}
this.reset();
});
},
alert(params) {
// support string param for easier porting of bootbox.alert
if (typeof params === "string") {
return this.dialog({
message: params,
type: "alert",
});
}
return this.dialog({
...params,
type: "alert",
});
},
confirm(params) {
return this.dialog({
...params,
shouldDisplayCancel: true,
buttons: null,
type: "confirm",
});
},
notice(message) {
return this.dialog({
message,
type: "notice",
});
},
yesNoConfirm(params) {
return this.confirm({
...params,
confirmButtonLabel: "yes_value",
cancelButtonLabel: "no_value",
});
},
reset() {
this.setProperties({
message: null,
type: null,
dialogInstance: null,
title: null,
titleElementId: null,
confirmButtonLabel: null,
confirmButtonIcon: null,
cancelButtonLabel: null,
shouldDisplayCancel: null,
didConfirm: null,
didCancel: null,
buttons: null,
class: null,
_confirming: false,
});
},
willDestroy() {
this.dialogInstance?.destroy();
this.reset();
},
@bind
didConfirmWrapped() {
if (this.didConfirm) {
this.didConfirm();
}
this._confirming = true;
this.dialogInstance.hide();
},
@bind
cancel() {
this.dialogInstance.hide();
},
});

View File

@ -0,0 +1 @@
export { default } from "dialog-holder/components/dialog-holder";

View File

@ -0,0 +1,9 @@
"use strict";
module.exports = {
name: require("./package").name,
isDevelopingAddon() {
return true;
},
};

View File

@ -0,0 +1,15 @@
{
"name": "dialog-holder",
"keywords": [
"ember-addon"
],
"dependencies": {
"a11y-dialog": "7.5.0",
"ember-auto-import": "^2.4.2",
"ember-cli-babel": "^7.26.10",
"ember-cli-htmlbars": "^6.1.0"
},
"devDependencies": {
"webpack": "^5.73.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@
"@discourse/itsatrap": "^2.0.10", "@discourse/itsatrap": "^2.0.10",
"@ember/jquery": "^2.0.0", "@ember/jquery": "^2.0.0",
"@ember/optional-features": "^2.0.0", "@ember/optional-features": "^2.0.0",
"@ember/render-modifiers": "^2.0.4",
"@ember/test-helpers": "^2.8.1", "@ember/test-helpers": "^2.8.1",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@glimmer/syntax": "^0.84.2", "@glimmer/syntax": "^0.84.2",
@ -32,6 +33,7 @@
"@uppy/drop-target": "^1.1.3", "@uppy/drop-target": "^1.1.3",
"@uppy/utils": "^4.1.0", "@uppy/utils": "^4.1.0",
"@uppy/xhr-upload": "^2.1.2", "@uppy/xhr-upload": "^2.1.2",
"a11y-dialog": "7.5.0",
"admin": "^1.0.0", "admin": "^1.0.0",
"discourse-plugins": "^1.0.0", "discourse-plugins": "^1.0.0",
"bootstrap": "3.4.1", "bootstrap": "3.4.1",
@ -58,13 +60,12 @@
"ember-exam": "^7.0.1", "ember-exam": "^7.0.1",
"ember-export-application-global": "^2.0.1", "ember-export-application-global": "^2.0.1",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^2.1.1",
"ember-modifier": "^3.2.7",
"ember-on-resize-modifier": "^1.1.0",
"ember-qunit": "^5.1.5", "ember-qunit": "^5.1.5",
"ember-rfc176-data": "^0.3.17", "ember-rfc176-data": "^0.3.17",
"ember-source": "~3.28.8", "ember-source": "~3.28.8",
"ember-test-selectors": "^6.0.0", "ember-test-selectors": "^6.0.0",
"ember-modifier": "^3.2.7",
"ember-on-resize-modifier": "^1.1.0",
"@ember/render-modifiers": "^2.0.4",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-qunit": "^6.2.0", "eslint-plugin-qunit": "^6.2.0",
"html-entities": "^2.3.3", "html-entities": "^2.3.3",
@ -96,6 +97,7 @@
}, },
"ember-addon": { "ember-addon": {
"paths": [ "paths": [
"lib/dialog-holder",
"lib/bootstrap-json" "lib/bootstrap-json"
] ]
}, },

View File

@ -51,7 +51,11 @@ acceptance("Admin - Emails", function (needs) {
await fillIn(".admin-controls input", "test@example.com"); await fillIn(".admin-controls input", "test@example.com");
await click(".btn-primary"); await click(".btn-primary");
assert.ok(query(".bootbox.modal").innerText.includes("some error")); assert.ok(query("#dialog-holder").innerText.includes("some error"));
await click(".bootbox .btn-primary"); assert.ok(
query("#dialog-holder .dialog-body b"),
"Error message can contain html"
);
await click(".dialog-overlay");
}); });
}); });

View File

@ -51,9 +51,9 @@ acceptance("Admin - Site Texts", function (needs) {
// Revert the changes // Revert the changes
await click(".revert-site-text"); await click(".revert-site-text");
assert.ok(exists(".bootbox.modal")); assert.ok(exists("#dialog-holder .dialog-content"));
await click(".bootbox.modal .btn-primary"); await click("#dialog-holder .btn-primary");
assert.ok(!exists(".saved")); assert.ok(!exists(".saved"));
assert.ok(!exists(".revert-site-text")); assert.ok(!exists(".revert-site-text"));

View File

@ -195,12 +195,13 @@ acceptance("Admin - User Index", function (needs) {
test("grant admin - shows the confirmation bootbox", async function (assert) { test("grant admin - shows the confirmation bootbox", async function (assert) {
await visit("/admin/users/3/user1"); await visit("/admin/users/3/user1");
await click(".grant-admin"); await click(".grant-admin");
assert.ok(exists(".bootbox")); assert.ok(exists(".dialog-content"));
assert.strictEqual( assert.strictEqual(
I18n.t("admin.user.grant_admin_confirm"), I18n.t("admin.user.grant_admin_confirm"),
query(".modal-body").textContent.trim() query(".dialog-body").textContent.trim()
); );
await click(".bootbox .btn-primary");
await click(".dialog-footer .btn-primary");
}); });
test("grant admin - redirects to the 2fa page", async function (assert) { test("grant admin - redirects to the 2fa page", async function (assert) {

View File

@ -317,7 +317,10 @@ acceptance("Composer", function (needs) {
await fillIn(".d-editor-input", "this is the content of the first reply"); await fillIn(".d-editor-input", "this is the content of the first reply");
await visit("/t/this-is-a-test-topic/9"); await visit("/t/this-is-a-test-topic/9");
assert.strictEqual(currentURL(), "/t/this-is-a-test-topic/9"); assert.ok(
currentURL().startsWith("/t/this-is-a-test-topic/9"),
"moves to second topic"
);
await click("#topic-footer-buttons .btn.create"); await click("#topic-footer-buttons .btn.create");
assert.ok( assert.ok(
exists(".discard-draft-modal.modal"), exists(".discard-draft-modal.modal"),

View File

@ -1,6 +1,6 @@
import { acceptance, query } from "../helpers/qunit-helpers"; import { acceptance, query } from "../helpers/qunit-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { visit } from "@ember/test-helpers"; import { click, visit } from "@ember/test-helpers";
import I18n from "I18n"; import I18n from "I18n";
acceptance("User Activity / Replies - empty state", function (needs) { acceptance("User Activity / Replies - empty state", function (needs) {
@ -33,3 +33,39 @@ acceptance("User Activity / Replies - empty state", function (needs) {
); );
}); });
}); });
acceptance("User Activity / Replies - Download All", function (needs) {
const currentUser = "eviltrout";
const anotherUser = "charlie";
needs.user();
needs.pretender((server, helper) => {
server.post("/export_csv/export_entity.json", () => {
return helper.response({});
});
});
test("Can see and trigger download for own data replies", async function (assert) {
await visit(`/u/${currentUser}/activity`);
assert.ok(query(".user-additional-controls .btn"), "button exists");
await click(".user-additional-controls .btn");
await click("#dialog-holder .btn-primary");
assert.equal(
query(".dialog-body").innerText.trim(),
I18n.t("user.download_archive.success")
);
await click("#dialog-holder .btn-primary");
});
test("Cannot see 'Download All' button for another user", async function (assert) {
await visit(`/u/${anotherUser}/activity`);
assert.notOk(
query(".user-additional-controls .btn"),
"download button is not present"
);
});
});

View File

@ -0,0 +1,367 @@
import I18n from "I18n";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, render, settled, triggerKeyEvent } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { query } from "discourse/tests/helpers/qunit-helpers";
module("Integration | Component | dialog-holder", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.dialog = this.container.lookup("service:dialog");
});
test("basics", async function (assert) {
await render(hbs`<DialogHolder />`);
assert.ok(query("#dialog-holder"), "element is in DOM");
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog is empty by default"
);
this.dialog.alert({
message: "This is an error",
});
await settled();
assert.ok(
query(".dialog-overlay").offsetWidth > 0,
true,
"overlay is visible"
);
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"This is an error",
"dialog has error message"
);
// dismiss by clicking on overlay
await click(".dialog-overlay");
assert.ok(query("#dialog-holder"), "element is still in DOM");
assert.strictEqual(
query(".dialog-overlay").offsetWidth,
0,
"overlay is not visible"
);
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog is empty"
);
});
test("basics - dismiss using Esc", async function (assert) {
let cancelCallbackCalled = false;
await render(hbs`<DialogHolder />`);
assert.ok(query("#dialog-holder"), "element is in DOM");
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog is empty by default"
);
this.dialog.alert({
message: "This is an error",
didCancel: () => {
cancelCallbackCalled = true;
},
});
await settled();
assert.ok(
query(".dialog-overlay").offsetWidth > 0,
true,
"overlay is visible"
);
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"This is an error",
"dialog has error message"
);
// dismiss by pressing Esc
await triggerKeyEvent(document, "keydown", "Escape");
assert.ok(cancelCallbackCalled, "cancel callback called");
assert.ok(query("#dialog-holder"), "element is still in DOM");
assert.strictEqual(
query(".dialog-overlay").offsetWidth,
0,
"overlay is not visible"
);
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog is empty"
);
});
test("alert with title", async function (assert) {
await render(hbs`<DialogHolder />`);
this.dialog.alert({
message: "This is a note.",
title: "And this is a title",
});
await settled();
assert.strictEqual(
query("#dialog-title").innerText.trim(),
"And this is a title",
"dialog has title"
);
assert.ok(
query("#dialog-holder[aria-labelledby='dialog-title']"),
"aria-labelledby is correctly set"
);
assert.ok(query(".dialog-close"), "close button present");
assert.ok(query("#dialog-holder"), "element is still in DOM");
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"This is a note.",
"dialog message is shown"
);
await click(".dialog-close");
assert.ok(query("#dialog-holder"), "element is still in DOM");
assert.strictEqual(
query(".dialog-overlay").offsetWidth,
0,
"overlay is not visible"
);
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog is empty"
);
});
test("alert with a string parameter", async function (assert) {
await render(hbs`<DialogHolder />`);
this.dialog.alert("An alert message");
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"An alert message",
"dialog message is shown"
);
});
test("confirm", async function (assert) {
let confirmCallbackCalled = false;
let cancelCallbackCalled = false;
await render(hbs`<DialogHolder />`);
this.dialog.confirm({
message: "A confirm message",
didConfirm: () => {
confirmCallbackCalled = true;
},
didCancel: () => {
cancelCallbackCalled = true;
},
});
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"A confirm message",
"dialog message is shown"
);
assert.strictEqual(
query(".dialog-footer .btn-primary").innerText.trim(),
I18n.t("ok_value"),
"dialog primary button says Ok"
);
assert.strictEqual(
query(".dialog-footer .btn-default").innerText.trim(),
I18n.t("cancel_value"),
"dialog second button is present and says No"
);
await click(".dialog-footer .btn-primary");
assert.ok(confirmCallbackCalled, "confirm callback called");
assert.notOk(cancelCallbackCalled, "cancel callback NOT called");
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog is empty"
);
});
test("cancel callback", async function (assert) {
let confirmCallbackCalled = false;
let cancelCallbackCalled = false;
await render(hbs`<DialogHolder />`);
this.dialog.confirm({
message: "A confirm message",
didConfirm: () => {
confirmCallbackCalled = true;
},
didCancel: () => {
cancelCallbackCalled = true;
},
});
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"A confirm message",
"dialog message is shown"
);
await click(".dialog-footer .btn-default");
assert.notOk(confirmCallbackCalled, "confirm callback NOT called");
assert.ok(cancelCallbackCalled, "cancel callback called");
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog has been dismissed"
);
});
test("yes/no confirm", async function (assert) {
await render(hbs`<DialogHolder />`);
this.dialog.yesNoConfirm({ message: "A yes/no confirm message" });
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"A yes/no confirm message",
"dialog message is shown"
);
assert.strictEqual(
query(".dialog-footer .btn-primary").innerText.trim(),
I18n.t("yes_value"),
"dialog primary button says Yes"
);
assert.strictEqual(
query(".dialog-footer .btn-default").innerText.trim(),
I18n.t("no_value"),
"dialog second button is present and says No"
);
});
test("alert with custom buttons", async function (assert) {
let customCallbackTriggered = false;
await render(hbs`<DialogHolder />`);
this.dialog.alert({
message: "An alert with custom buttons",
buttons: [
{
icon: "cog",
label: "Danger ahead",
class: "btn-danger",
action: () => {
return new Promise((resolve) => {
customCallbackTriggered = true;
return resolve();
});
},
},
],
});
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"An alert with custom buttons",
"dialog message is shown"
);
assert.strictEqual(
query(".dialog-footer .btn-danger").innerText.trim(),
"Danger ahead",
"dialog custom button is present"
);
assert.notOk(
query(".dialog-footer .btn-primary"),
"default confirm button is not present"
);
assert.notOk(
query(".dialog-footer .btn-default"),
"default cancel button is not present"
);
await click(".dialog-footer .btn-danger");
assert.ok(customCallbackTriggered, "custom action was triggered");
assert.strictEqual(
query("#dialog-holder").innerText.trim(),
"",
"dialog has been dismissed"
);
});
test("alert with custom classes", async function (assert) {
await render(hbs`<DialogHolder />`);
this.dialog.alert({
message: "An alert with custom classes",
class: "dialog-special dialog-super",
});
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"An alert with custom classes",
"dialog message is shown"
);
assert.ok(
query("#dialog-holder.dialog-special.dialog-super"),
"additional classes are present"
);
await click(".dialog-footer .btn-primary");
assert.notOk(
query("#dialog-holder.dialog-special"),
"additional class removed on dismissal"
);
assert.notOk(
query("#dialog-holder.dialog-super"),
"additional class removed on dismissal"
);
});
test("notice", async function (assert) {
await render(hbs`<DialogHolder />`);
this.dialog.notice("Noted!");
await settled();
assert.strictEqual(
query(".dialog-body").innerText.trim(),
"Noted!",
"message is shown"
);
assert.notOk(query(".dialog-footer"), "no footer");
assert.notOk(query(".dialog-header"), "no header");
});
});

View File

@ -1853,6 +1853,13 @@
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
a11y-dialog@7.5.0:
version "7.5.0"
resolved "https://registry.yarnpkg.com/a11y-dialog/-/a11y-dialog-7.5.0.tgz#1540627b18e3b1e266e0dcbdb5d1e7ac52079fe1"
integrity sha512-UF7cy4lfZQtvjRV5N4xdWFba+Pb1qW6FPp0p58dLjMTJ4PwIGGekTbmqUt3etBBRo9HbTqhlNsXQhzIuXeJpng==
dependencies:
focusable-selectors "^0.3.1"
abab@^2.0.6: abab@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
@ -5783,6 +5790,11 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3" inherits "^2.0.3"
readable-stream "^2.3.6" readable-stream "^2.3.6"
focusable-selectors@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/focusable-selectors/-/focusable-selectors-0.3.1.tgz#7eacbca8dc6cc8d7f7563e5f5cc3699b91e20aaa"
integrity sha512-5JLtr0e1YJIfmnVlpLiG+av07dd0Xkf/KfswsXcei5KmLfdwOysTQsjF058ynXniujb1fvev7nql1x+CkC5ikw==
follow-redirects@^1.0.0: follow-redirects@^1.0.0:
version "1.13.3" version "1.13.3"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"

View File

@ -12,6 +12,7 @@
@import "crawler_layout"; @import "crawler_layout";
@import "d-icon"; @import "d-icon";
@import "d-popover"; @import "d-popover";
@import "dialog";
@import "directory"; @import "directory";
@import "discourse"; @import "discourse";
@import "edit-category"; @import "edit-category";

View File

@ -0,0 +1,78 @@
.dialog-container,
.dialog-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.dialog-container {
z-index: z("modal", "overlay");
display: flex;
}
/**
* Ensures the dialog container and all its descendants are not
* visible and not focusable when it is hidden.
*/
.dialog-container[aria-hidden="true"] {
display: none;
}
@keyframes fade-in {
from {
opacity: 0;
}
}
.dialog-overlay {
background: rgba(var(--always-black-rgb), 0.65);
animation: fade-in 250ms both;
}
.dialog-content {
margin: auto;
z-index: z("modal", "content");
position: relative;
background-color: var(--secondary);
animation: fade-in 250ms both;
box-shadow: shadow("card");
min-width: 40vw;
}
.dialog-body {
overflow-y: auto;
max-height: 400px;
padding: 1em;
}
.dialog-header {
display: flex;
padding: 10px 15px;
border-bottom: 1px solid var(--primary-low);
align-items: center;
h3 {
font-size: var(--font-up-3);
margin-bottom: 0;
}
.dialog-close {
margin-left: auto;
.d-icon {
color: var(--primary-high);
}
}
}
.dialog-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 14px 15px 10px;
border-top: 1px solid var(--primary-low);
--btn-bottom-margin: 0.3em;
.btn {
margin: 0 0.75em var(--btn-bottom-margin) 0;
}
}

View File

@ -251,6 +251,8 @@ en:
not_implemented: "That feature hasn't been implemented yet, sorry!" not_implemented: "That feature hasn't been implemented yet, sorry!"
no_value: "No" no_value: "No"
yes_value: "Yes" yes_value: "Yes"
ok_value: "OK"
cancel_value: "Cancel"
submit: "Submit" submit: "Submit"
generic_error: "Sorry, an error has occurred." generic_error: "Sorry, an error has occurred."
generic_error_with_reason: "An error occurred: %{error}" generic_error_with_reason: "An error occurred: %{error}"