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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 4547 additions and 152 deletions

View File

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

View File

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

View File

@ -1,11 +1,13 @@
import Controller from "@ember/controller";
import I18n from "I18n";
import bootbox from "bootbox";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import discourseComputed from "discourse-common/utils/decorators";
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"), {
dialog: service(),
saved: false,
queryParams: ["locale"],
@ -14,35 +16,36 @@ export default Controller.extend(bufferedProperty("siteText"), {
return this.siteText.value === value;
},
actions: {
saveChanges() {
const attrs = this.buffered.getProperties("value");
attrs.locale = this.locale;
@action
saveChanges() {
const attrs = this.buffered.getProperties("value");
attrs.locale = this.locale;
this.siteText
.save(attrs)
.then(() => {
this.commitBuffer();
this.set("saved", true);
})
.catch(popupAjaxError);
},
this.siteText
.save(attrs)
.then(() => {
this.commitBuffer();
this.set("saved", true);
})
.catch(popupAjaxError);
},
revertChanges() {
this.set("saved", false);
@action
revertChanges() {
this.set("saved", false);
bootbox.confirm(I18n.t("admin.site_text.revert_confirm"), (result) => {
if (result) {
this.siteText
.revert(this.locale)
.then((props) => {
const buffered = this.buffered;
buffered.setProperties(props);
this.commitBuffer();
})
.catch(popupAjaxError);
}
});
},
this.dialog.yesNoConfirm({
message: I18n.t("admin.site_text.revert_confirm"),
didConfirm: () => {
this.siteText
.revert(this.locale)
.then((props) => {
const buffered = this.buffered;
buffered.setProperties(props);
this.commitBuffer();
})
.catch(popupAjaxError);
},
});
},
});

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@
<PluginOutlet @name="below-footer" @connectorTagName="div" @args={{hash showFooter=this.showFooter}} />
{{outlet "modal"}}
<DialogHolder />
<TopicEntrance />
{{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",
"@ember/jquery": "^2.0.0",
"@ember/optional-features": "^2.0.0",
"@ember/render-modifiers": "^2.0.4",
"@ember/test-helpers": "^2.8.1",
"@glimmer/component": "^1.1.2",
"@glimmer/syntax": "^0.84.2",
@ -32,6 +33,7 @@
"@uppy/drop-target": "^1.1.3",
"@uppy/utils": "^4.1.0",
"@uppy/xhr-upload": "^2.1.2",
"a11y-dialog": "7.5.0",
"admin": "^1.0.0",
"discourse-plugins": "^1.0.0",
"bootstrap": "3.4.1",
@ -58,13 +60,12 @@
"ember-exam": "^7.0.1",
"ember-export-application-global": "^2.0.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-rfc176-data": "^0.3.17",
"ember-source": "~3.28.8",
"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-plugin-qunit": "^6.2.0",
"html-entities": "^2.3.3",
@ -96,6 +97,7 @@
},
"ember-addon": {
"paths": [
"lib/dialog-holder",
"lib/bootstrap-json"
]
},

View File

@ -51,7 +51,11 @@ acceptance("Admin - Emails", function (needs) {
await fillIn(".admin-controls input", "test@example.com");
await click(".btn-primary");
assert.ok(query(".bootbox.modal").innerText.includes("some error"));
await click(".bootbox .btn-primary");
assert.ok(query("#dialog-holder").innerText.includes("some error"));
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
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(".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) {
await visit("/admin/users/3/user1");
await click(".grant-admin");
assert.ok(exists(".bootbox"));
assert.ok(exists(".dialog-content"));
assert.strictEqual(
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) {

View File

@ -317,7 +317,10 @@ acceptance("Composer", function (needs) {
await fillIn(".d-editor-input", "this is the content of the first reply");
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");
assert.ok(
exists(".discard-draft-modal.modal"),

View File

@ -1,6 +1,6 @@
import { acceptance, query } from "../helpers/qunit-helpers";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { click, visit } from "@ember/test-helpers";
import I18n from "I18n";
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"
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:
version "2.0.6"
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"
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:
version "1.13.3"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"

View File

@ -12,6 +12,7 @@
@import "crawler_layout";
@import "d-icon";
@import "d-popover";
@import "dialog";
@import "directory";
@import "discourse";
@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!"
no_value: "No"
yes_value: "Yes"
ok_value: "OK"
cancel_value: "Cancel"
submit: "Submit"
generic_error: "Sorry, an error has occurred."
generic_error_with_reason: "An error occurred: %{error}"