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 00000000000..b4752881d22 --- /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 00000000000..409a3becbd7 --- /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}} + +{{else}} + +{{/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 00000000000..4c82e9d510d --- /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 57eb631bd2d..02745f3cf00 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 00000000000..c9945168f08 --- /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 96af2e71dd9..86ec2b065c4 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..."