DEV: add pick-files-button component (#13764)

DEV: add pick-files-button component (#13764)

  • DEV: add pick-files-button component
  • Scope querySelector to the component, add removeEventListener, fix formatting
diff --git a/app/assets/javascripts/discourse/app/components/pick-files-button.js b/app/assets/javascripts/discourse/app/components/pick-files-button.js
new file mode 100644
index 0000000..b475288
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/pick-files-button.js
@@ -0,0 +1,106 @@
+import Component from "@ember/component";
+import { action } from "@ember/object";
+import { empty } from "@ember/object/computed";
+import { bind, default as computed } from "discourse-common/utils/decorators";
+import I18n from "I18n";
+
+export default Component.extend({
+  classNames: ["pick-files-button"],
+  acceptedFileTypes: null,
+  acceptAnyFile: empty("acceptedFileTypes"),
+
+  didInsertElement() {
+    this._super(...arguments);
+    const fileInput = this.element.querySelector("input");
+    this.set("fileInput", fileInput);
+    fileInput.addEventListener("change", this.onChange, false);
+  },
+
+  willDestroyElement() {
+    this._super(...arguments);
+    this.fileInput.removeEventListener("change", this.onChange);
+  },
+
+  @bind
+  onChange() {
+    const files = this.fileInput.files;
+    this._filesPicked(files);
+  },
+
+  @computed
+  acceptedFileTypesString() {
+    if (!this.acceptedFileTypes) {
+      return null;
+    }
+
+    return this.acceptedFileTypes.join(",");
+  },
+
+  @computed
+  acceptedExtensions() {
+    if (!this.acceptedFileTypes) {
+      return null;
+    }
+
+    return this.acceptedFileTypes
+      .filter((type) => type.startsWith("."))
+      .map((type) => type.substring(1));
+  },
+
+  @computed
+  acceptedMimeTypes() {
+    if (!this.acceptedFileTypes) {
+      return null;
+    }
+
+    return this.acceptedFileTypes.filter((type) => !type.startsWith("."));
+  },
+
+  @action
+  openSystemFilePicker() {
+    this.fileInput.click();
+  },
+
+  _filesPicked(files) {
+    if (!files || !files.length) {
+      return;
+    }
+
+    if (!this._haveAcceptedTypes(files)) {
+      const message = I18n.t("pick_files_button.unsupported_file_picked", {
+        types: this.acceptedFileTypesString,
+      });
+      bootbox.alert(message);
+      return;
+    }
+    this.onFilesPicked(files);
+  },
+
+  _haveAcceptedTypes(files) {
+    for (const file of files) {
+      if (
+        !(this._hasAcceptedExtension(file) && this._hasAcceptedMimeType(file))
+      ) {
+        return false;
+      }
+    }
+    return true;
+  },
+
+  _hasAcceptedExtension(file) {
+    const extension = this._fileExtension(file.name);
+    return (
+      !this.acceptedExtensions || this.acceptedExtensions.includes(extension)
+    );
+  },
+
+  _hasAcceptedMimeType(file) {
+    return (
+      !this.acceptedMimeTypes || this.acceptedMimeTypes.includes(file.type)
+    );
+  },
+
+  _fileExtension(fileName) {
+    return fileName.split(".").pop();
+  },
+});
diff --git a/app/assets/javascripts/discourse/app/templates/components/pick-files-button.hbs b/app/assets/javascripts/discourse/app/templates/components/pick-files-button.hbs
new file mode 100644
index 0000000..409a3be
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/pick-files-button.hbs
@@ -0,0 +1,6 @@
+{{d-button action=(action "openSystemFilePicker") label=label icon=icon}}
+{{#if acceptAnyFile}}
+  <input type="file">
+{{else}}
+  <input type="file" accept={{acceptedFileTypesString}}>
+{{/if}}
diff --git a/app/assets/javascripts/discourse/tests/integration/components/pick-files-button-tests.js b/app/assets/javascripts/discourse/tests/integration/components/pick-files-button-tests.js
new file mode 100644
index 0000000..4c82e9d
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/pick-files-button-tests.js
@@ -0,0 +1,78 @@
+import componentTest, {
+  setupRenderingTest,
+} from "discourse/tests/helpers/component-test";
+import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
+import hbs from "htmlbars-inline-precompile";
+import { triggerEvent } from "@ember/test-helpers";
+import sinon from "sinon";
+
+function createBlob(mimeType, extension) {
+  const blob = new Blob(["content"], {
+    type: mimeType,
+  });
+  blob.name = `filename${extension}`;
+  return blob;
+}
+
+discourseModule(
+  "Integration | Component | pick-files-button",
+  function (hooks) {
+    setupRenderingTest(hooks);
+
+    componentTest(
+      "it shows alert if a file with an unsupported extension was chosen",
+      {
+        skip: true,
+        template: hbs`
+        {{pick-files-button
+          acceptedFileTypes=this.acceptedFileTypes
+          onFilesChosen=this.onFilesChosen}}`,
+
+        beforeEach() {
+          const expectedExtension = ".json";
+          this.set("acceptedFileTypes", [expectedExtension]);
+          this.set("onFilesChosen", () => {});
+        },
+
+        async test(assert) {
+          sinon.stub(bootbox, "alert");
+
+          const wrongExtension = ".txt";
+          const file = createBlob("text/json", wrongExtension);
+
+          await triggerEvent("input#file-input", "change", { files: [file] });
+
+          assert.ok(bootbox.alert.calledOnce);
+        },
+      }
+    );
+
+    componentTest(
+      "it shows alert if a file with an unsupported MIME type was chosen",
+      {
+        skip: true,
+        template: hbs`
+        {{pick-files-button
+          acceptedFileTypes=this.acceptedFileTypes
+          onFilesChosen=this.onFilesChosen}}`,
+
+        beforeEach() {
+          const expectedMimeType = "text/json";
+          this.set("acceptedFileTypes", [expectedMimeType]);
+          this.set("onFilesChosen", () => {});
+        },
+
+        async test(assert) {
+          sinon.stub(bootbox, "alert");
+
+          const wrongMimeType = "text/plain";
+          const file = createBlob(wrongMimeType, ".json");
+
+          await triggerEvent("input#file-input", "change", { files: [file] });
+
+          assert.ok(bootbox.alert.calledOnce);
+        },
+      }
+    );
+  }
+);
diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss
index 57eb631..02745f3 100644
--- a/app/assets/stylesheets/common/components/_index.scss
+++ b/app/assets/stylesheets/common/components/_index.scss
@@ -17,6 +17,7 @@
 @import "ignored-user-list";
 @import "keyboard_shortcuts";
 @import "navs";
+@import "pick-files-button";
 @import "relative-time-picker";
 @import "share-and-invite-modal";
 @import "svg";
diff --git a/app/assets/stylesheets/common/components/pick-files-button.scss b/app/assets/stylesheets/common/components/pick-files-button.scss
new file mode 100644
index 0000000..c994516
--- /dev/null
+++ b/app/assets/stylesheets/common/components/pick-files-button.scss
@@ -0,0 +1,5 @@
+.pick-files-button {
+  input[type="file"] {
+    display: none;
+  }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 96af2e7..86ec2b0 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3784,6 +3784,9 @@ en:
         leader: "leader"
       detailed_name: "%{level}: %{name}"
 
+    pick_files_button:
+      unsupported_file_picked: "You have picked an unsupported file. Supported file types – %{types}."
+
   # This section is exported to the javascript for i18n in the admin section
   admin_js:
     type_to_filter: "type to filter..."

GitHub sha: 27b97e4f648da1587e97c5f661da145e12a27bea

This commit appears in #13764 which was approved by eviltrout. It was merged by AndrewPrigorshnev.