FEATURE: Accept the flag modal on CTRL + ENTER and CMD + ENTER (#13497)

FEATURE: Accept the flag modal on CTRL + ENTER and CMD + ENTER (#13497)

We want to submit the flag modal on pressing CTRL + ENTER and CMD + ENTER.

Here’s how our modals work:

Every modal can be dismissed by pressing ESC. This behaviour can be disabled for a specific modal if we need to. Every modal can be submitted by pressing ENTER if the cursor wasn’t on a text area or a form at the moment of pressing. Now, the flag modal is actually a one big form and pressing ENTER doesn’t submit it. I’ve added submitting by CTRL+ENTER but at first it was interfering with the basic modal submitting by ENTER. It’s a pretty tricky thing to fix because we use the keyup event for submitting by ENTER and we need to use the keydown event for submitting with modifiers (because submitting by CMD+ENTER on Macs doesn’t work with keyup).

Eventually, I fixed the problem just by adding a possibility to disable default submitting on ENTER (in the same way as we already have the possibility of disabling dismissing on ESC). Then I disabled default submitting for the flag form and implemented submitting by CTRL+ENTER and CMD+ENTER. This way everything is simple and robust. I did it only for the flag modal but it’ll be easy and safe to add the same behaviour to another modal.

diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.js b/app/assets/javascripts/discourse/app/components/d-modal-body.js
index 27e0921..120d00e 100644
--- a/app/assets/javascripts/discourse/app/components/d-modal-body.js
+++ b/app/assets/javascripts/discourse/app/components/d-modal-body.js
@@ -3,6 +3,7 @@ import { scheduleOnce } from "@ember/runloop";
 export default Component.extend({
   classNames: ["modal-body"],
   fixed: false,
+  submitOnEnter: true,
   dismissable: true,
   autoFocus: true,
 
@@ -49,6 +50,7 @@ export default Component.extend({
         "fixed",
         "subtitle",
         "rawSubtitle",
+        "submitOnEnter",
         "dismissable",
         "headerClass",
         "autoFocus"
diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js
index 533e03a..652035b 100644
--- a/app/assets/javascripts/discourse/app/components/d-modal.js
+++ b/app/assets/javascripts/discourse/app/components/d-modal.js
@@ -19,6 +19,7 @@ export default Component.extend({
     "role",
     "ariaLabelledby:aria-labelledby",
   ],
+  submitOnEnter: true,
   dismissable: true,
   title: null,
   subtitle: null,
@@ -48,7 +49,7 @@ export default Component.extend({
   @on("didInsertElement")
   setUp() {
     $("html").on("keyup.discourse-modal", (e) => {
-      //only respond to events when the modal is visible
+      // only respond to events when the modal is visible
       if (!this.element.classList.contains("hidden")) {
         if (e.which === 27 && this.dismissable) {
           next(() => this.attrs.closeModal("initiatedByESC"));
@@ -70,6 +71,10 @@ export default Component.extend({
   },
 
   triggerClickOnEnter(e) {
+    if (!this.submitOnEnter) {
+      return false;
+    }
+
     // skip when in a form or a textarea element
     if (
       e.target.closest("form") ||
@@ -124,6 +129,10 @@ export default Component.extend({
       this.set("subtitle", null);
     }
 
+    if ("submitOnEnter" in data) {
+      this.set("submitOnEnter", data.submitOnEnter);
+    }
+
     if ("dismissable" in data) {
       this.set("dismissable", data.dismissable);
     } else {
diff --git a/app/assets/javascripts/discourse/app/controllers/flag.js b/app/assets/javascripts/discourse/app/controllers/flag.js
index 4872698..87dbf1c 100644
--- a/app/assets/javascripts/discourse/app/controllers/flag.js
+++ b/app/assets/javascripts/discourse/app/controllers/flag.js
@@ -1,3 +1,4 @@
+import { schedule } from "@ember/runloop";
 import ActionSummary from "discourse/models/action-summary";
 import Controller from "@ember/controller";
 import EmberObject from "@ember/object";
@@ -6,7 +7,7 @@ import { MAX_MESSAGE_LENGTH } from "discourse/models/post-action-type";
 import ModalFunctionality from "discourse/mixins/modal-functionality";
 import { Promise } from "rsvp";
 import User from "discourse/models/user";
-import discourseComputed from "discourse-common/utils/decorators";
+import discourseComputed, { bind } from "discourse-common/utils/decorators";
 import { not } from "@ember/object/computed";
 import optionalService from "discourse/lib/optional-service";
 import { popupAjaxError } from "discourse/lib/ajax-error";
@@ -52,6 +53,17 @@ export default Controller.extend(ModalFunctionality, {
     };
   },
 
+  @bind
+  keyDown(event) {
+    // CTRL+ENTER or CMD+ENTER
+    if (event.keyCode === 13 && (event.ctrlKey || event.metaKey)) {
+      if (this.submitEnabled) {
+        this.send("createFlag");
+        return false;
+      }
+    }
+  },
+
   clientSuspend(performAction) {
     this._penalize("showSuspendModal", performAction);
   },
@@ -85,6 +97,16 @@ export default Controller.extend(ModalFunctionality, {
         this.set("spammerDetails", result);
       });
     }
+
+    schedule("afterRender", () => {
+      const element = document.querySelector(".flag-modal");
+      element.addEventListener("keydown", this.keyDown);
+    });
+  },
+
+  onClose() {
+    const element = document.querySelector(".flag-modal");
+    element.removeEventListener("keydown", this.keyDown);
   },
 
   @discourseComputed("spammerDetails.canDelete", "selected.name_key")
diff --git a/app/assets/javascripts/discourse/app/templates/modal/flag.hbs b/app/assets/javascripts/discourse/app/templates/modal/flag.hbs
index 0cea23d..4e11f46 100644
--- a/app/assets/javascripts/discourse/app/templates/modal/flag.hbs
+++ b/app/assets/javascripts/discourse/app/templates/modal/flag.hbs
@@ -1,4 +1,4 @@
-{{#d-modal-body class="flag-modal-body" title=title}}
+{{#d-modal-body class="flag-modal-body" title=title submitOnEnter=false}}
   <form>
     {{#flag-selection nameKey=selected.name_key flags=flagsAvailable as |f|}}
       {{flag-action-type
diff --git a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js
index ef40fe0..926e27b 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js
@@ -2,11 +2,13 @@ import {
   acceptance,
   count,
   exists,
+  query,
 } from "discourse/tests/helpers/qunit-helpers";
 import { click, visit } from "@ember/test-helpers";
 import selectKit from "discourse/tests/helpers/select-kit-helper";
-import { test } from "qunit";
+import { skip, test } from "qunit";
 import userFixtures from "discourse/tests/fixtures/user-fixtures";
+import { run } from "@ember/runloop";
 
 async function openFlagModal() {
   if (exists(".topic-post:first-child button.show-more-actions")) {
@@ -15,6 +17,14 @@ async function openFlagModal() {
   await click(".topic-post:first-child button.create-flag");
 }
 
+function keyDown(element, keyCode, modifier) {
+  const event = document.createEvent("Event");
+  event.initEvent("keydown", true, true);
+  event.keyCode = 13;
+  event[modifier] = true;
+  run(() => element.dispatchEvent(event));
+}
+
 acceptance("flagging", function (needs) {
   needs.user();
   needs.pretender((server, helper) => {
@@ -142,4 +152,36 @@ acceptance("flagging", function (needs) {
     await click(".modal-footer .btn-primary");
     assert.ok(!exists(".bootbox.modal:visible"));
   });
+
+  skip("CTRL + ENTER accepts the modal", async function (assert) {
+    await visit("/t/internationalization-localization/280");
+    await openFlagModal();
+
+    const modal = query("#discourse-modal");
+    keyDown(modal, 13, "ctrlKey");
+    assert.ok(
+      exists("#discourse-modal:visible"),
+      "The modal wasn't closed because the accept button was disabled"
+    );
+
+    await click("#radio_inappropriate"); // this enables the accept button
+    keyDown(modal, 13, "ctrlKey");
+    assert.ok(!exists("#discourse-modal:visible"), "The modal was closed");
+  });
+
+  skip("CMD or WINDOWS-KEY + ENTER accepts the modal", async function (assert) {
+    await visit("/t/internationalization-localization/280");
+    await openFlagModal();
+
+    const modal = query("#discourse-modal");
+    keyDown(modal, 13, "metaKey");
+    assert.ok(
+      exists("#discourse-modal:visible"),
+      "The modal wasn't closed because the accept button was disabled"
+    );
+
+    await click("#radio_inappropriate"); // this enables the accept button
+    keyDown(modal, 13, "ctrlKey");
+    assert.ok(!exists("#discourse-modal:visible"), "The modal was closed");
+  });
 });

GitHub sha: cf1e8b27640721f2eff9d6c8244a0fef867e8f4c

This commit appears in #13497 which was approved by ZogStriP. It was merged by AndrewPrigorshnev.