diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js index d4abe0b5..62b6ec9d 100644 --- a/packages/oui-angular/src/index.js +++ b/packages/oui-angular/src/index.js @@ -13,6 +13,7 @@ import Datagrid from "@ovh-ui/oui-datagrid"; import Dropdown from "@ovh-ui/oui-dropdown"; import DualList from "@ovh-ui/oui-dual-list"; import Field from "@ovh-ui/oui-field"; +import File from "@ovh-ui/oui-file"; import FormActions from "@ovh-ui/oui-form-actions"; import GuideMenu from "@ovh-ui/oui-guide-menu"; import HeaderTabs from "@ovh-ui/oui-header-tabs"; @@ -56,6 +57,7 @@ export default angular Dropdown, DualList, Field, + File, FormActions, GuideMenu, HeaderTabs, diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js index 2553124f..d62f271c 100644 --- a/packages/oui-angular/src/index.spec.js +++ b/packages/oui-angular/src/index.spec.js @@ -15,6 +15,7 @@ loadTests(require.context("../../oui-datagrid/src/", true, /.*((\.spec)|(index)) loadTests(require.context("../../oui-dropdown/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-dual-list/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-field/src/", true, /.*((\.spec)|(index))$/)); +loadTests(require.context("../../oui-file/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-form-actions/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-guide-menu/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-header-tabs/src/", true, /.*((\.spec)|(index))$/)); diff --git a/packages/oui-field/src/field.html b/packages/oui-field/src/field.html index 20b844ed..bdaa96f8 100644 --- a/packages/oui-field/src/field.html +++ b/packages/oui-field/src/field.html @@ -23,7 +23,7 @@ ng-if="$ctrl.isErrorVisible()"> - diff --git a/packages/oui-field/src/field.provider.js b/packages/oui-field/src/field.provider.js index ae4010cb..738e1d56 100644 --- a/packages/oui-field/src/field.provider.js +++ b/packages/oui-field/src/field.provider.js @@ -11,6 +11,7 @@ export default class { max: "Too high ({{max}} max).", minlength: "Too short ({{minlength}} characters min).", maxlength: "Too high ({{maxlength}} characters max).", + maxsize: "This file exceeds the size limit", pattern: "Invalid format." } }; diff --git a/packages/oui-file/README.md b/packages/oui-file/README.md new file mode 100644 index 00000000..28b51c56 --- /dev/null +++ b/packages/oui-file/README.md @@ -0,0 +1,118 @@ +# File + + + +## Selector + +### Basic + +```html:preview + +``` + +### Placeholder + +```html:preview + +``` + +### Disabled + +```html:preview + +``` + +### File restriction + +`maxsize` support form validators + +```html:preview +
+ + + + +

fileForm.$valid value: {{fileForm.$valid | json}}

+

fileForm.$submitted value: {{fileForm.$submitted | json}}

+

fileForm["fileUpload"].$error value: {{fileForm["fileUpload"].$error | json}}

+ Submit +
+``` + +## Multiple files + +### Basic + +```html:preview + +``` + +### With preview + +Preview works only with `image/*` files. + +```html:preview + +``` + +## Drag & Drop area + +### Basic + +```html:preview + +``` + +### With preview + +Preview works only with `image/*` files. + +```html:preview + +``` + +## API + +| Attribute | Type | Binding | One-time binding | Values | Default | Description +| ---- | ---- | ---- | ---- | ---- | ---- | ---- +| `model` | array<file> | = | no | n/a | n/a | model bound to component +| `id` | string | @? | yes | n/a | n/a | id attribute of form input +| `name` | string | @? | yes | n/a | n/a | name attribute of form input +| `placeholder` | string | @? | yes | n/a | n/a | placeholder text +| `accept` | string | @? | yes | n/a | n/a | accept attribute of file input +| `maxsize` | number | { + ouiFileConfigurationProvider.setTranslations({ // default translations + attachmentsHeading: "Attachment(s)", + dropArea: "Attach document(s) by drap and drop or", + dropAreaSelector: "select a file", + fileSelector: "Select file", + filesSelector: "Select file(s)...", + maxsizeError: "This file exceeds the size limit", + removeFile: "Remove file from selector" + }); + ouiFileConfigurationProvider.setUnits([ // default units + { size: 1000000000, suffix: "GB" }, + { size: 1000000, suffix: "MB" }, + { size: 1000, suffix: "KB" }, + { size: 1, suffix: "B" } + ]); +}); +``` diff --git a/packages/oui-file/package.json b/packages/oui-file/package.json new file mode 100644 index 00000000..b9479ace --- /dev/null +++ b/packages/oui-file/package.json @@ -0,0 +1,7 @@ +{ + "name": "@ovh-ui/oui-file", + "version": "1.0.0", + "main": "./src/index.js", + "license": "BSD-3-Clause", + "author": "OVH SAS" +} diff --git a/packages/oui-file/src/file.component.js b/packages/oui-file/src/file.component.js new file mode 100644 index 00000000..e2ab2831 --- /dev/null +++ b/packages/oui-file/src/file.component.js @@ -0,0 +1,24 @@ +import controller from "./file.controller"; +import template from "./file.html"; + +export default { + require: { + form: "?^^form" + }, + bindings: { + model: "=", + id: "@?", + name: "@?", + placeholder: "@?", + accept: "@?", + maxsize: " this.maxsize) { + file.errors.maxsize = true; + } + + // Set form validation + if (this.form && this.form[this.name]) { + this.form[this.name].$setValidity("maxsize", !file.errors.maxsize); + this.form[this.name].$setDirty(); + } + + // Clean errors + if (isEmpty(file.errors)) { + delete file.errors; + } + } + + return file; + } + + loadFilePreview (file) { + // Load preview only if image + if (this.preview && !file.errors && file.type && file.type.search(/^image\//) !== -1) { + file.loading = true; + + file.reader = new this.$window.FileReader(); + file.reader.readAsDataURL(file); + file.reader.onload = () => { + file.preview = `url("${file.reader.result}")`; + + this.$scope.$apply(); + }; + } + + return file; + } + + addFile (file) { + this.getFileInfos(file); + this.checkFileValidity(file); + + this.model = [file]; + this.onSelect({ modelValue: this.model }); + this.$scope.$apply(); + + // Set back focus on fake selector + this.$element[0].querySelector(".oui-file-selector__label").focus(); + } + + addFiles (files) { + if (!this.model) { + this.model = []; + } + + if (angular.isArray(files)) { + files.forEach((file) => { + // Check for duplicate before adding + if (!find(this.model, (item) => file.name === item.name)) { + this.getFileInfos(file); + this.checkFileValidity(file); + this.loadFilePreview(file); + this.model.push(file); + } + }); + + this.onSelect({ modelValue: this.model }); + this.$scope.$apply(); + } + } + + removeFile (file) { + if (angular.isArray(this.model)) { + remove(this.model, (item) => item === file); + } + } + + resetFile () { + this.model = undefined; + this.fileSelector[0].value = ""; + + if (this.form && this.form[this.name]) { + this.form[this.name].$setValidity("maxsize", true); + } + } + + openFileSelector () { + // triggerHandler("click") don't work here + this.fileSelector[0].click(); + } + + getFileInfos (file) { + const parts = file.name.split("."); + const extension = parts.length > 1 ? parts.pop() : undefined; + const name = parts.join("."); + + file.infos = { + extension, + name, + size: this.getFileSize(file) + }; + + return file; + } + + getFileSize (file) { + let size; + + // Get best extension for file size + for (const unit of this.units) { + size = Math.floor(file.size / unit.size); + if (size > 1) { + size = `(${size} ${unit.suffix})`; + break; + } + } + + return size; + } + + setInputTouched () { + if (this.form && this.form[this.name]) { + // Set input[hidden] to touched for form validation + this.form[this.name].$setTouched(); + } + } + + $onInit () { + addBooleanParameter(this, "disabled"); + addBooleanParameter(this, "required"); + addBooleanParameter(this, "multiple"); + addBooleanParameter(this, "droparea"); + addBooleanParameter(this, "preview"); + + addDefaultParameter(this, "id", `ouiFile${this.$scope.$id}`); + addDefaultParameter(this, "name", `ouiFile${this.$scope.$id}`); + + this.selectorId = `${this.id}Selector`; + this.dropareaId = `${this.id}Droparea`; + this.attachments = Boolean(this.multiple || this.droparea || this.preview); + } + + $postLink () { + this.$timeout(() => { + this.$element + .addClass("oui-file") + .removeAttr("id") + .removeAttr("name"); + + // ngChange don't work on input file + this.fileSelector = angular.element(this.$element[0].querySelector(`#${this.selectorId}`)); + this.fileSelector.on("change", (e) => { + if (this.attachments) { + // FileList from input file is read-only + // Needed to be port as an array for manipulation + this.addFiles(Array.from(e.target.files)); + } else { + this.addFile(e.target.files[0]); + } + }); + + if (this.droparea) { + this.fileDroparea = angular.element(this.$element[0].querySelector(`#${this.dropareaId}`)); + this.fileDroparea + .on("drag dragstart dragend dragover dragenter dragleave drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + }) + .on("dragover dragenter", () => this.fileDroparea.addClass("oui-file-droparea_dragover")) + .on("dragleave dragend drop", () => this.fileDroparea.removeClass("oui-file-droparea_dragover")) + .on("drop", (e) => { + // FileList from input file is read-only + // Needed to be port as an array for manipulation + if (e.dataTransfer) { + this.addFiles(Array.from(e.dataTransfer.files)); + } + }); + } + }); + } +} diff --git a/packages/oui-file/src/file.html b/packages/oui-file/src/file.html new file mode 100644 index 00000000..d6b7eeed --- /dev/null +++ b/packages/oui-file/src/file.html @@ -0,0 +1,138 @@ + + + + + + + +
+ + +
+ + +
+ + + +
+ + + +
+ +
+ + + +
+ + + {{::$ctrl.translations.dropAreaSelector}} + +
+ + + +
+

+

+ +
+ diff --git a/packages/oui-file/src/file.provider.js b/packages/oui-file/src/file.provider.js new file mode 100644 index 00000000..d99d6cd4 --- /dev/null +++ b/packages/oui-file/src/file.provider.js @@ -0,0 +1,48 @@ +import merge from "lodash/merge"; +import union from "lodash/union"; + +export default class { + constructor () { + this.translations = { + attachmentsHeading: "Attachment(s)", + dropArea: "Attach document(s) by drap and drop or", + dropAreaSelector: "select a file", + fileSelector: "Select file", + filesSelector: "Select file(s)...", + maxsizeError: "This file exceeds the size limit", + removeFile: "Remove file from selector" + }; + + this.units = [ + { size: 1000000000, suffix: "GB" }, + { size: 1000000, suffix: "MB" }, + { size: 1000, suffix: "KB" }, + { size: 1, suffix: "B" } + ]; + } + + /** + * Set the translations for the file component + * @param {Object} translations a map of translations + */ + setTranslations (translations) { + this.translations = merge(this.translations, translations); + return this; + } + + /** + * Set the units for the file component + * @param {Array} units array + */ + setUnits (units) { + this.units = union(this.units, units); + return this; + } + + $get () { + return angular.copy({ + translations: this.translations, + units: this.units + }); + } +} diff --git a/packages/oui-file/src/index.js b/packages/oui-file/src/index.js new file mode 100644 index 00000000..30d3f1fa --- /dev/null +++ b/packages/oui-file/src/index.js @@ -0,0 +1,8 @@ +import File from "./file.component"; +import FileProvider from "./file.provider"; + +export default angular + .module("oui.file", []) + .component("ouiFile", File) + .provider("ouiFileConfiguration", FileProvider) + .name; diff --git a/packages/oui-file/src/index.spec.js b/packages/oui-file/src/index.spec.js new file mode 100644 index 00000000..e5b921cc --- /dev/null +++ b/packages/oui-file/src/index.spec.js @@ -0,0 +1,326 @@ +import find from "lodash/find"; + +describe("ouiFile", () => { + let $timeout; + let TestUtils; + + const getAttachments = (element) => element[0].querySelector(".oui-file-attachments"); + const getDropArea = (element) => element[0].querySelector(".oui-file-droparea"); + const getInputFile = (element) => element[0].querySelector("input[type='file']"); + const getInputHidden = (element) => element[0].querySelector("input[type='hidden']"); + const getMultipleSelector = (element) => element[0].querySelector(".oui-file-multiple"); + const getCustomSelector = (element) => element[0].querySelector(".oui-file-selector"); + + const mockFile = { + name: "test.png", + size: 150000, + type: "image/png" + }; + + const mockFiles = [mockFile]; + + beforeEach(angular.mock.module("oui.file")); + beforeEach(angular.mock.module("oui.file.configuration")); + beforeEach(angular.mock.module("oui.test-utils")); + + beforeEach(inject((_$timeout_, _TestUtils_) => { + $timeout = _$timeout_; + TestUtils = _TestUtils_; + })); + + describe("Provider", () => { + let configuration; + const foo = { foo: "bar" }; + + angular.module("oui.file.configuration", [ + "oui.file" + ]).config(ouiFileConfigurationProvider => { + ouiFileConfigurationProvider.setTranslations(foo); + ouiFileConfigurationProvider.setUnits([foo]); + }); + + beforeEach(inject(_ouiFileConfiguration_ => { + configuration = _ouiFileConfiguration_; + })); + + it("should have custom translations", () => { + expect(configuration.translations.foo).toEqual("bar"); + }); + + it("should have custom units", () => { + expect(find(configuration.units, foo)).toBeDefined(); + }); + }); + + describe("Component", () => { + describe("Basic", () => { + let element; + let controller; + let selector; + let inputFile; + let inputHidden; + + beforeEach(() => { + element = TestUtils.compileTemplate(''); + controller = element.controller("ouiFile"); + + $timeout.flush(); + + selector = getCustomSelector(element); + inputFile = getInputFile(element); + inputHidden = getInputHidden(element); + }); + + it("should have a default classname", () => { + expect(element.hasClass("oui-file")).toBeTruthy(); + }); + + it("should have form inputs and a custom selector", () => { + expect(inputFile).toBeDefined(); + expect(inputHidden).toBeDefined(); + expect(selector).toBeDefined(); + }); + + it("should have default id and name attributes on form inputs", () => { + expect(inputFile.id).toBeDefined(); + expect(inputHidden.id).toBeDefined(); + expect(inputHidden.name).toBeDefined(); + }); + + it("should open file selector", () => { + const onClickSpy = jasmine.createSpy("onClickSpy"); + controller.fileSelector.on("click", onClickSpy); + controller.openFileSelector(); + expect(onClickSpy).toHaveBeenCalled(); + }); + + it("should add file when changed", () => { + controller.fileSelector.triggerHandler({ + type: "change", + target: { + files: mockFiles // Mock FileList + } + }); + }); + }); + + describe("Multiple", () => { + let element; + let controller; + let inputFile; + let selector; + let attachments; + + beforeEach(() => { + element = TestUtils.compileTemplate(''); + controller = element.controller("ouiFile"); + + $timeout.flush(); + + inputFile = getInputFile(element); + selector = getMultipleSelector(element); + attachments = getAttachments(element); + }); + + it("should have input file multiple", () => { + expect(inputFile.multiple).toBeTruthy(); + }); + + it("should show multiple selector and attachments", () => { + expect(selector).toBeTruthy(); + expect(attachments).toBeTruthy(); + }); + + it("should add file when changed", () => { + controller.fileSelector.triggerHandler({ + type: "change", + target: { + files: mockFiles // Mock FileList + } + }); + }); + }); + + describe("Drop area", () => { + let element; + let controller; + let inputFile; + let selector; + let attachments; + + beforeEach(() => { + element = TestUtils.compileTemplate(''); + controller = element.controller("ouiFile"); + + $timeout.flush(); + + inputFile = getInputFile(element); + selector = getDropArea(element); + attachments = getAttachments(element); + }); + + it("should have input file multiple", () => { + expect(inputFile.multiple).toBeTruthy(); + }); + + it("should show drop area and attachments", () => { + expect(selector).toBeTruthy(); + expect(attachments).toBeTruthy(); + }); + + it("should update classname with drag & drop events", () => { + controller.fileDroparea.triggerHandler("dragenter"); + expect(controller.fileDroparea.hasClass("oui-file-droparea_dragover")).toBeTruthy(); + + controller.fileDroparea.triggerHandler("dragleave"); + expect(controller.fileDroparea.hasClass("oui-file-droparea_dragover")).toBeFalsy(); + }); + + it("should add files when dropped", () => { + controller.fileDroparea.triggerHandler({ + type: "drop", + dataTransfer: { + files: mockFiles + } + }); + }); + }); + + describe("File management", () => { + let element; + let controller; + let onSelectSpy; + + beforeEach(() => { + onSelectSpy = jasmine.createSpy("onSelectSpy"); + element = TestUtils.compileTemplate(` + + `, { + onSelectSpy + }); + controller = element.controller("ouiFile"); + + $timeout.flush(); + }); + + it("should return file's informations", () => { + const infos = controller.getFileInfos(mockFile).infos; + expect(infos.name).toBe("test"); + expect(infos.extension).toBe("png"); + expect(infos.size).toBe("(150 KB)"); + }); + + it("should return undefined as extesion", () => { + const infos = controller.getFileInfos({ name: "test" }).infos; + expect(infos.extension).toBeUndefined(); + }); + + it("should check file validity", () => { + controller.maxsize = 200000; + expect(controller.checkFileValidity(mockFile).errors).toBeUndefined(); + controller.maxsize = 100000; + expect(controller.checkFileValidity(mockFile).errors).toBeDefined(); + }); + + it("should add file to model", () => { + controller.addFile(mockFile); + expect(controller.model).toBeDefined(); + expect(controller.model.length).toBe(1); + expect(onSelectSpy).toHaveBeenCalledWith(controller.model); + }); + + it("should reset model", () => { + controller.addFile(mockFile); + controller.resetFile(); + expect(controller.model).toBeUndefined(); + }); + + it("should add files to model", () => { + controller.addFiles(mockFiles); + expect(controller.model).toBeDefined(); + expect(controller.model.length).toBe(1); + expect(onSelectSpy).toHaveBeenCalledWith(controller.model); + }); + + it("should not add duplicate file", () => { + controller.addFiles(mockFiles); + controller.addFiles(mockFiles); + expect(controller.model.length).toBe(1); + }); + + it("should remove file", () => { + controller.addFiles(mockFiles); + controller.removeFile(mockFile); + expect(controller.model.length).toBe(0); + }); + + it("should load a preview", (done) => { + const delay = 500; + controller.preview = true; + const file = controller.loadFilePreview(mockFile); + setTimeout(() => { + expect(file.loading).toBeTruthy(); + expect(file.reader).toBeDefined(); + done(); + }, delay); + }); + }); + + describe("Form controls", () => { + const id = "foo"; + const name = "bar"; + let element; + let controller; + let inputFile; + let inputHidden; + + beforeEach(() => { + element = TestUtils.compileTemplate(`
+ +
`); + controller = element.find("oui-file").controller("ouiFile"); + + $timeout.flush(); + + inputFile = getInputFile(element); + inputHidden = getInputHidden(element); + }); + + it("should move id and name attributes on form input", () => { + expect(inputFile.id).toBe(`${id}Selector`); + expect(inputHidden.id).toBe(id); + expect(inputHidden.name).toBe(name); + + expect(element.find("oui-file").attr("id")).toBeUndefined(); + expect(element.find("oui-file").attr("name")).toBeUndefined(); + }); + + it("should set input form $touched", () => { + const label = angular.element(element[0].querySelector(".oui-file-selector__label")); + + expect(controller.form[name].$touched).toBeFalsy(); + label.triggerHandler("blur"); + expect(controller.form[name].$touched).toBeTruthy(); + }); + + it("should update angular form validition", () => { + expect(controller.form[name].$dirty).toBeFalsy(); + + controller.maxsize = 200000; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.maxsize).toBeUndefined(); + + expect(controller.form[name].$dirty).toBeTruthy(); + + controller.maxsize = 100000; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.maxsize).toBeTruthy(); + + controller.resetFile(); + expect(controller.form[name].$error.maxsize).toBeUndefined(); + }); + }); + }); +});