diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 267e84a67a..75b634fb98 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -284,6 +284,17 @@ import InitialStateTree from './widgets/tree/InitialState'; import PopupConfirmation from './widgets/popup-confirmation/Basic'; import PopupConfirmationUnderlay from './widgets/popup-confirmation/Underlay'; import ClickableCard from './widgets/card/ClickableCard'; +import BasicFileUploadInput from './widgets/file-upload-input/Basic'; +import DisabledFileUploadInput from './widgets/file-upload-input/Disabled'; +import LabelledFileUploadInput from './widgets/file-upload-input/Labelled'; +import MultipleFileUploadInput from './widgets/file-upload-input/Multiple'; +import NoDropFileUploadInput from './widgets/file-upload-input/NoDrop'; +import BasicFileUploader from './widgets/file-uploader/Basic'; +import ControlledFileUploader from './widgets/file-uploader/Controlled'; +import CustomValidatorFileUploader from './widgets/file-uploader/CustomValidator'; +import DisabledFileUploader from './widgets/file-uploader/Disabled'; +import MultipleFileUploader from './widgets/file-uploader/Multiple'; +import ValidatedFileUploader from './widgets/file-uploader/Validated'; import * as dojoDarkVariant from '@dojo/widgets/theme/dojo/variants/dark.m.css'; import * as materialDarkVariant from '@dojo/widgets/theme/material/variants/dark.m.css'; @@ -810,6 +821,76 @@ export const config = { } } }, + 'file-upload-input': { + filename: 'index', + overview: { + example: { + title: 'Basic FileUploadInput', + filename: 'Basic', + module: BasicFileUploadInput + } + }, + examples: [ + { + title: 'Disabled FileUploadInput', + filename: 'Disabled', + module: DisabledFileUploadInput + }, + { + title: 'Multiple FileUploadInput', + filename: 'Multiple', + module: MultipleFileUploadInput, + description: + 'Demonstrates using child `content` property to render information about the uploaded files that is available to the `onValue` callback.' + }, + { + title: 'FileUploadInput with label', + filename: 'Labelled', + module: LabelledFileUploadInput + }, + { + title: 'FileUploadInput with no DnD', + filename: 'NoDrop', + module: NoDropFileUploadInput + } + ] + }, + 'file-uploader': { + filename: 'index', + overview: { + example: { + filename: 'Basic', + module: BasicFileUploader + } + }, + examples: [ + { + title: 'Disabled FileUploader', + filename: 'Disabled', + module: DisabledFileUploader + }, + { + title: 'Multiple FileUploader', + filename: 'Multiple', + module: MultipleFileUploader + }, + { + title: 'Validated FileUploader', + filename: 'Validated', + module: ValidatedFileUploader + }, + { + title: 'FileUploader with custom validator', + filename: 'CustomValidator', + module: CustomValidatorFileUploader + }, + { + title: 'Controlled FileUploader', + filename: 'Controlled', + module: ControlledFileUploader + } + ] + }, 'floating-action-button': { overview: { example: { diff --git a/src/examples/src/widgets/file-upload-input/Basic.tsx b/src/examples/src/widgets/file-upload-input/Basic.tsx new file mode 100644 index 0000000000..ea6e88afa2 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Basic.tsx @@ -0,0 +1,21 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function Basic({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/Disabled.tsx b/src/examples/src/widgets/file-upload-input/Disabled.tsx new file mode 100644 index 0000000000..d141db4700 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Disabled.tsx @@ -0,0 +1,17 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function Disabled() { + function onValue() { + // do something with files + } + + return ( + + + + ); +}); diff --git a/src/examples/src/widgets/file-upload-input/Labelled.tsx b/src/examples/src/widgets/file-upload-input/Labelled.tsx new file mode 100644 index 0000000000..b8bd81fac3 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Labelled.tsx @@ -0,0 +1,25 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function Labelled({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + + {{ + label: 'Upload a profile image' + }} + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/Multiple.tsx b/src/examples/src/widgets/file-upload-input/Multiple.tsx new file mode 100644 index 0000000000..0fd64b3921 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Multiple.tsx @@ -0,0 +1,49 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +import * as css from './multiple.m.css'; + +const factory = create({ icache }); + +export default factory(function Multiple({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + + {{ + content: selectedFiles.length ? ( + + + + + + + + + {selectedFiles.map(function(file) { + return ( + + + + + + + ); + })} + +
NameModifiedTypeBytes
{file.name}{new Date(file.lastModified).toLocaleString()}{file.type}{String(file.size)}
+ ) : ( + '' + ) + }} +
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/NoDrop.tsx b/src/examples/src/widgets/file-upload-input/NoDrop.tsx new file mode 100644 index 0000000000..c28f5bc39e --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/NoDrop.tsx @@ -0,0 +1,21 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function NoDrop({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/multiple.m.css b/src/examples/src/widgets/file-upload-input/multiple.m.css new file mode 100644 index 0000000000..b76e63c8c2 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/multiple.m.css @@ -0,0 +1,3 @@ +.table { + width: 100%; +} diff --git a/src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts b/src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts new file mode 100644 index 0000000000..a6f2a84d49 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts @@ -0,0 +1 @@ +export const table: string; diff --git a/src/examples/src/widgets/file-uploader/Basic.tsx b/src/examples/src/widgets/file-uploader/Basic.tsx new file mode 100644 index 0000000000..3bdb400a26 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Basic.tsx @@ -0,0 +1,17 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import FileUploader from '@dojo/widgets/file-uploader'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function Basic() { + function onValue() { + // do something with files + } + + return ( + + + + ); +}); diff --git a/src/examples/src/widgets/file-uploader/Controlled.tsx b/src/examples/src/widgets/file-uploader/Controlled.tsx new file mode 100644 index 0000000000..5e8e0f1936 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Controlled.tsx @@ -0,0 +1,46 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import FileUploader, { FileWithValidation } from '@dojo/widgets/file-uploader'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function Controlled({ middleware: { icache } }) { + function validateFiles(files: FileWithValidation[]) { + return files.map(function(file) { + // Files bigger than 100KB are marked invalid + const valid = file.size <= 100 * 1024; + file.valid = valid; + // Each file can include a message for the valid state as well as invalid + file.message = valid ? 'File is valid' : 'File is too big'; + + return file; + }); + } + + // onValue receives any files selected from the file dialog or + // dragged and dropped from the OS + function onValue(files: File[]) { + // Validation and manipulation of the selected files is done + // entirely external to the FileUploader widget. + // This line both validates the files and truncates the total count to 4. + const validatedFiles = validateFiles(files).slice(0, 4); + + icache.set('files', validatedFiles); + } + + // If FileUploader receives a value for `files` then it will only render that. + // If it receives a falsy value then it will render whatever files the user selects. + // To ensure no files are rendered pass an empty array. + const files = icache.getOrSet('files', []); + + return ( + + + {{ + label: 'Controlled FileUploader' + }} + + + ); +}); diff --git a/src/examples/src/widgets/file-uploader/CustomValidator.tsx b/src/examples/src/widgets/file-uploader/CustomValidator.tsx new file mode 100644 index 0000000000..43fd555a34 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/CustomValidator.tsx @@ -0,0 +1,32 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import FileUploader from '@dojo/widgets/file-uploader'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function CustomValidator() { + function onValue() { + // do something with files + } + + function validateName(file: File) { + if (file.name === 'validfile.txt') { + return { valid: true }; + } else { + return { + message: 'File name must be "validfile.txt"', + valid: false + }; + } + } + + return ( + + + {{ + label: 'Upload a file named "validfile.txt"' + }} + + + ); +}); diff --git a/src/examples/src/widgets/file-uploader/Disabled.tsx b/src/examples/src/widgets/file-uploader/Disabled.tsx new file mode 100644 index 0000000000..074886a640 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Disabled.tsx @@ -0,0 +1,17 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import FileUploader from '@dojo/widgets/file-uploader'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function Disabled() { + function onValue() { + // do something with files + } + + return ( + + + + ); +}); diff --git a/src/examples/src/widgets/file-uploader/Multiple.tsx b/src/examples/src/widgets/file-uploader/Multiple.tsx new file mode 100644 index 0000000000..6e889c2453 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Multiple.tsx @@ -0,0 +1,17 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import FileUploader from '@dojo/widgets/file-uploader'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function Multiple() { + function onValue() { + // do something with files + } + + return ( + + + + ); +}); diff --git a/src/examples/src/widgets/file-uploader/Validated.tsx b/src/examples/src/widgets/file-uploader/Validated.tsx new file mode 100644 index 0000000000..2070d0df64 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Validated.tsx @@ -0,0 +1,20 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import FileUploader from '@dojo/widgets/file-uploader'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function Validated() { + const accept = 'image/jpeg,image/png'; + const maxSize = 50000; + + function onValue() { + // do something with files + } + + return ( + + + + ); +}); diff --git a/src/file-upload-input/README.md b/src/file-upload-input/README.md new file mode 100644 index 0000000000..def04d414f --- /dev/null +++ b/src/file-upload-input/README.md @@ -0,0 +1,16 @@ +# @dojo/widgets/file-upload-input + +Dojo's `FileUploadInput` provides an interface for managing file uploads supporting both ``. This is a controlled widget that only provides file selection. The `FileUploader` widget provides more full-featured file upload functionality. If you require more customization than `FileUploader` provides you can build a custom file uploader widget based on `FileUploadInput`. You can provide a callback function to the `onValue` property to receive a `File` array whenever files are selected. + +## Features + +- Single or multiple file upload +- Add files from OS-provided file selection dialog + +### Keyboard features + +- Trigger file selection dialog with keyboard + +### i18n features + +- Localized version of labels for the button and DnD can be provided in nls resources diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx new file mode 100644 index 0000000000..7aa45a3398 --- /dev/null +++ b/src/file-upload-input/index.tsx @@ -0,0 +1,150 @@ +import { DojoEvent, RenderResult } from '@dojo/framework/core/interfaces'; +import i18n from '@dojo/framework/core/middleware/i18n'; +import { create, node, tsx } from '@dojo/framework/core/vdom'; +import { Button } from '../button'; +import { formatAriaProperties } from '../common/util'; +import { Label } from '../label'; +import theme from '../middleware/theme'; +import bundle from './nls/FileUploadInput'; + +import * as css from '../theme/default/file-upload-input.m.css'; +import * as baseCss from '../theme/default/base.m.css'; +import * as buttonCss from '../theme/default/button.m.css'; +import * as labelCss from '../theme/default/label.m.css'; + +export interface FileValidation { + message?: string; + valid?: boolean; +} + +export interface FileUploadInputChildren { + /** Label displayed above the input */ + label?: RenderResult; + + /** Content rendered within the upload area */ + content?: RenderResult; +} + +export interface FileUploadInputProperties { + /** Determines what file types this input accepts */ + accept?: string; + + /** Custom aria attributes */ + aria?: { [key: string]: string | null }; + + /** Determines if this input is disabled */ + disabled?: boolean; + + /** Hides the label for a11y purposes */ + labelHidden?: boolean; + + /** Allows multiple files to be uploaded at once */ + multiple?: boolean; + + /** Name given to this attribute within a form */ + name?: string; + + /** Callback called when the user selects files */ + onValue(value: File[]): void; + + /** Determines if a value is required for this input */ + required?: boolean; + + /** Represents if the selected files passed internal validation */ + valid?: boolean | FileValidation; +} + +const factory = create({ i18n, node, theme }) + .properties() + .children(); + +export const FileUploadInput = factory(function FileUploadInput({ + children, + id, + middleware: { i18n, node, theme }, + properties +}) { + const { + accept, + aria = {}, + disabled = false, + labelHidden = false, + multiple = false, + name, + onValue, + required = false, + valid = true + } = properties(); + + const { messages } = i18n.localize(bundle); + const themeCss = theme.classes(css); + const { content = undefined, label = undefined } = children()[0] || {}; + + function clickNativeButton() { + // Certain browsers such as Firefox 80 require direct DOM access for fileInputNode.click() to fire. + const nativeInputNode = node.get('nativeInput'); + nativeInputNode && nativeInputNode.click(); + } + + function onChange(event: DojoEvent) { + if (event.target.files && event.target.files.length) { + onValue(Array.from(event.target.files)); + } + } + + return ( +
+ {label && ( + + )} + +
+ + +
+ + {content} +
+ ); +}); + +export default FileUploadInput; diff --git a/src/file-upload-input/nls/FileUploadInput.ts b/src/file-upload-input/nls/FileUploadInput.ts new file mode 100644 index 0000000000..b76e20a32c --- /dev/null +++ b/src/file-upload-input/nls/FileUploadInput.ts @@ -0,0 +1,6 @@ +const messages = { + chooseFiles: 'Choose files…', + orDropFilesHere: 'Or drop files here' +}; + +export default { messages }; diff --git a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx new file mode 100644 index 0000000000..b506d301da --- /dev/null +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -0,0 +1,174 @@ +import { tsx } from '@dojo/framework/core/vdom'; +import { assertion, renderer, wrap } from '@dojo/framework/testing/renderer'; +import * as sinon from 'sinon'; +import { Button } from '../../../button'; +import { FileUploadInput } from '../../index'; +import { Label } from '../../../label'; +import { noop, stubEvent } from '../../../common/tests/support/test-helpers'; + +import bundle from '../../nls/FileUploadInput'; +import * as baseCss from '../../../theme/default/base.m.css'; +import * as buttonCss from '../../../theme/default/button.m.css'; +import * as css from '../../../theme/default/file-upload-input.m.css'; +import * as labelCss from '../../../theme/default/label.m.css'; + +const { after, afterEach, describe, it } = intern.getInterface('bdd'); +const { messages } = bundle; + +describe('FileUploadInput', function() { + const WrappedRoot = wrap('div'); + const WrappedWrapper = wrap('div'); + const WrappedInput = wrap('input'); + const WrappedButton = wrap(Button); + + const preventDefaultSpy = sinon.spy(stubEvent, 'preventDefault'); + + const baseAssertion = assertion(function() { + return ( + + + + + ); + }); + + after(function() { + preventDefaultSpy.restore(); + }); + + afterEach(function() { + preventDefaultSpy.resetHistory(); + }); + + it('renders', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + }); + + it('renders content', function() { + const content =
some content
; + + const r = renderer(function() { + return ( + + {{ + content + }} + + ); + }); + + r.expect(baseAssertion.insertAfter(WrappedWrapper, () => [content])); + }); + + it('renders label', function() { + const label = 'Widget label'; + + const r = renderer(function() { + return ( + + {{ + label + }} + + ); + }); + + r.expect( + baseAssertion.prepend(WrappedRoot, function() { + return [ + + ]; + }) + ); + }); + + it('renders disabled', function() { + const r = renderer(function() { + return ; + }); + + r.expect( + baseAssertion + .setProperty(WrappedRoot, 'aria-disabled', 'true') + .setProperty(WrappedRoot, 'classes', [null, css.root, css.disabled]) + .setProperty(WrappedInput, 'disabled', true) + .setProperty(WrappedButton, 'disabled', true) + ); + }); + + it('calls onValue when files are selected from input', function() { + const testValues = [1, 2, 3]; + const onValue = sinon.stub(); + + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion.setProperty(WrappedInput, 'multiple', true)); + r.property(WrappedInput, 'onchange', { + target: { + files: testValues + } + }); + r.expect(baseAssertion.setProperty(WrappedInput, 'multiple', true)); + + // TODO: enable when https://github.com/dojo/framework/pull/840 is merged + // assert.sameOrderedMembers(onValue.firstCall.args[0], testValues); + }); +}); diff --git a/src/file-uploader/README.md b/src/file-uploader/README.md new file mode 100644 index 0000000000..4543a70da4 --- /dev/null +++ b/src/file-uploader/README.md @@ -0,0 +1,20 @@ +# @dojo/widgets/file-uploader + +Dojo's `FileUploader` provides an interface for managing file uploads using the `FileUploadInput` widget internally. +Building on `FileUploadInput's` basic file uploading functionality this widget adds state management and visual +display of selected files as well as validation. + +## Features + +- Single or multiple file upload +- Add files from OS-provided file selection dialog +- View and remove added files +- Validation + +### Keyboard features + +- Trigger file selection dialog with keyboard + +### i18n features + +- Localized version of default labels for the button and DnD can be provided in nls resources diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx new file mode 100644 index 0000000000..fd9b48cb37 --- /dev/null +++ b/src/file-uploader/index.tsx @@ -0,0 +1,230 @@ +import { RenderResult } from '@dojo/framework/core/interfaces'; +import i18n from '@dojo/framework/core/middleware/i18n'; +import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; +import { create, tsx } from '@dojo/framework/core/vdom'; +import { + FileUploadInput, + FileUploadInputChildren, + FileUploadInputProperties, + FileValidation +} from '../file-upload-input'; +import { Icon } from '../icon'; +import theme from '../middleware/theme'; +import bundle from './nls/FileUploader'; + +import * as css from '../theme/default/file-uploader.m.css'; +import * as fileUploadInputCss from '../theme/default/file-upload-input.m.css'; + +export interface FileUploaderChildren { + label?: RenderResult; +} + +export interface FileUploaderProperties extends FileUploadInputProperties { + /** Custom validator used to validate each file */ + customValidator?: (file: File) => FileValidation | void; + + /** Files to render if using this widget in a controlled manner */ + files?: FileWithValidation[]; + + /** Max file size after which files will be marked as invalid */ + maxSize?: number; + + /** Called when input validation changes indicating aggregate file validate */ + onValidate?: (valid: boolean | undefined) => void; + + /** Called when the user selects files */ + onValue(value: FileWithValidation[]): void; + + /** Hide file sizes in the file list */ + hideFileSize?: boolean; +} + +export type FileWithValidation = File & FileValidation; + +const factorNames = ['', 'B', 'KB', 'MB', 'GB', 'TB', 'PB']; +export function formatBytes(byteCount: number) { + if (isNaN(byteCount)) { + return ''; + } + + let formattedValue = ''; + for (let i = 1; i < factorNames.length; i++) { + if (byteCount < Math.pow(1024, i) || i === factorNames.length - 1) { + formattedValue = `${(byteCount / Math.pow(1024, i - 1)).toFixed(i > 1 ? 2 : 0)} ${ + factorNames[i] + }`; + // values below the next factor up but greater than 1023.99 will round up to 1024.00 - push them down + if (formattedValue.startsWith('1024.00') && i < factorNames.length - 1) { + formattedValue = `1023.99 ${factorNames[i]}`; + } + break; + } + } + + return formattedValue; +} + +export interface FileUploaderIcache { + previousValidationState?: boolean; + value: FileWithValidation[]; +} + +const icache = createICacheMiddleware(); +const factory = create({ i18n, icache, theme }) + .properties() + .children(); + +export const FileUploader = factory(function FileUploader({ + children, + middleware: { i18n, icache, theme }, + properties +}) { + const { + accept, + customValidator, + disabled = false, + files: initialFiles, + hideFileSize, + maxSize, + multiple = false, + name, + onValidate, + onValue, + required = false + } = properties(); + const { messages } = i18n.localize(bundle); + const themeCss = theme.classes(css); + const inputChild = (children()[0] || {}) as FileUploadInputChildren; + let files = initialFiles || icache.getOrSet('value', []); + + function validateFiles(files: Array): FileWithValidation[] { + const previousValidationState = icache.get('previousValidationState'); + let currentValidationState = true; + + const validatedFiles = files.map(function(file) { + const validatedFile: FileWithValidation = file; + let message = ''; + let valid = maxSize ? file.size <= maxSize : true; + + if (valid) { + if (customValidator) { + const customValid = customValidator(file); + if (customValid) { + valid = customValid.valid !== false; + message = customValid.message || ''; + } + } + } else { + message = messages.invalidFileSize; + } + + currentValidationState = currentValidationState && valid; + + // It is important to use the original File object - creating a new object and assigning file's + // properties to it won't work for File's special methods. Even setting the File instance as the + // prototype of another object will result in failure when attempting to invoke File methods. + validatedFile.valid = valid; + validatedFile.message = message; + + return validatedFile; + }); + + if (currentValidationState !== previousValidationState) { + onValidate && onValidate(currentValidationState); + } + + return validatedFiles; + } + + function updateFiles(newFiles: Array) { + const validatedFiles = validateFiles(newFiles); + // only update the cache if the widget is not controlled + if (!initialFiles) { + icache.set('value', validatedFiles); + } + onValue(validatedFiles); + } + + function onInputValue(newFiles: File[]) { + const newValue = multiple ? files.concat(newFiles) : newFiles.slice(0, 1); + updateFiles(newValue); + } + + function remove(file: FileWithValidation) { + const fileIndex = files.indexOf(file); + /* istanbul ignore if (type-safety check; should never happen) */ + if (fileIndex === -1) { + return; + } else { + const updatedFiles = files.slice(); + updatedFiles.splice(fileIndex, 1); + updateFiles(updatedFiles); + } + } + + function renderFiles(files: FileWithValidation[]) { + return files.map(function(file) { + let FileValidation: FileValidation; + if ('valid' in file) { + FileValidation = { + valid: file.valid, + message: file.message + }; + } else { + FileValidation = { + valid: true, + message: '' + }; + } + + const { message, valid } = FileValidation; + + return ( +
+
+
{file.name}
+ {!hideFileSize && ( +
{formatBytes(file.size)}
+ )} + +
+ {message &&
{message}
} +
+ ); + }); + } + + inputChild.content = files.length ? [
{renderFiles(files)}
] : null; + + return ( +
+ + {inputChild} + +
+ ); +}); + +export default FileUploader; diff --git a/src/file-uploader/nls/FileUploader.ts b/src/file-uploader/nls/FileUploader.ts new file mode 100644 index 0000000000..7079a71d34 --- /dev/null +++ b/src/file-uploader/nls/FileUploader.ts @@ -0,0 +1,6 @@ +const messages = { + invalidFileSize: 'Invalid file size', + remove: 'Remove' +}; + +export default { messages }; diff --git a/src/file-uploader/tests/unit/FileUploader.spec.tsx b/src/file-uploader/tests/unit/FileUploader.spec.tsx new file mode 100644 index 0000000000..73e5847980 --- /dev/null +++ b/src/file-uploader/tests/unit/FileUploader.spec.tsx @@ -0,0 +1,332 @@ +import { tsx } from '@dojo/framework/core/vdom'; +import { assertion, renderer, wrap } from '@dojo/framework/testing/renderer'; +import { noop } from '../../../common/tests/support/test-helpers'; +import * as sinon from 'sinon'; +import FileUploader, { formatBytes } from '../../index'; +import FileUploadInput from '../../../file-upload-input'; +import Icon from '../../../icon'; + +import bundle from '../../nls/FileUploader'; +import * as css from '../../../theme/default/file-uploader.m.css'; +import * as fileUploadInputCss from '../../../theme/default/file-upload-input.m.css'; + +const { describe, it } = intern.getInterface('bdd'); +const { assert } = intern.getPlugin('chai'); +const { messages } = bundle; + +describe('FileUploader', function() { + const WrappedRoot = wrap('div'); + const WrappedFileUploadInput = wrap(FileUploadInput); + const WrappedButton = wrap('button'); + + const inputThemeProp = { + '@dojo/widgets/file-upload-input': { + disabled: fileUploadInputCss.disabled, + labelRoot: fileUploadInputCss.labelRoot, + root: fileUploadInputCss.root, + wrapper: fileUploadInputCss.wrapper + } + }; + + const baseAssertion = assertion(function() { + return ( + + + {{ + content: null + }} + + + ); + }); + + function getTestFiles() { + return [ + { + name: 'file1.jpg', + size: 55383, + formattedSize: '54.08 KB', + type: 'image/jpeg' + }, + { + name: 'file2.png', + size: 180240, + formattedSize: '176.02 KB', + type: 'image/png', + valid: false, + message: 'File is too big' + }, + { + name: 'file3.png', + size: 4001220, + formattedSize: '3.82 MB', + type: 'image/png', + valid: true, + message: 'File is great' + } + ]; + } + + function getRenderedFiles(files: ReturnType) { + return ( +
+ {files.map(function(file, index) { + return ( +
+
+
{file.name}
+
{formatBytes(file.size)}
+ {index === 0 ? ( + + + + ) : ( + + )} +
+ {file.message && ( +
{file.message}
+ )} +
+ ); + })} +
+ ); + } + + it('renders', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + }); + + it('sets props on FileUploadInput', function() { + const r = renderer(function() { + return ( + + ); + }); + + const setPropsAssertionTemplate = baseAssertion + .setProperty(WrappedRoot, 'classes', [null, css.root, css.disabled]) + .setProperty(WrappedFileUploadInput, 'onValue', noop) + .setProperty(WrappedFileUploadInput, 'accept', 'accept') + .setProperty(WrappedFileUploadInput, 'disabled', true) + .setProperty(WrappedFileUploadInput, 'multiple', true) + .setProperty(WrappedFileUploadInput, 'required', true) + .setProperty(WrappedFileUploadInput, 'name', 'name'); + + r.expect(setPropsAssertionTemplate); + }); + + it('renders label', function() { + const label = 'Widget label'; + + const r = renderer(function() { + return ( + + {{ + label + }} + + ); + }); + + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ label, content: '' }))); + }); + + it('renders files from property', function() { + const files = getTestFiles(); + const r = renderer(function() { + return ; + }); + const content = getRenderedFiles(files); + + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + }); + + it('renders files from FileUploadInput', function() { + const files = getTestFiles(); + const r = renderer(function() { + return ; + }); + const content = getRenderedFiles([files[0]]); + + r.expect(baseAssertion); + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + }); + + it('renders and validates multiple files from FileUploadInput', function() { + // This is implemented to produce the same output that getRenderedFiles(getTestFiles()) creates + function customValidator(file: ReturnType[number]) { + if (file.name === 'file2.png') { + return { + valid: false, + message: 'File is too big' + }; + } else if (file.name === 'file3.png') { + return { + message: 'File is great' + }; + } + } + + const files = getTestFiles(); + const r = renderer(function() { + return ( + + ); + }); + const multipleAssertion = baseAssertion.setProperty( + WrappedFileUploadInput, + 'multiple', + true + ); + + r.expect(multipleAssertion); + r.property(WrappedFileUploadInput, 'onValue', files as any); + + const content = getRenderedFiles(files); + r.expect(multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + }); + + it('renders added files cumulatively when multiple=true', function() { + const r = renderer(function() { + return ; + }); + const multipleAssertion = baseAssertion.setProperty( + WrappedFileUploadInput, + 'multiple', + true + ); + + r.expect(multipleAssertion); + const files = [{ name: 'file1', size: 100 }]; + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect( + multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles(files as any) + })) + ); + + const moreFiles = [{ name: 'file2', size: 200 }]; + r.property(WrappedFileUploadInput, 'onValue', moreFiles as any); + r.expect( + multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles((files as any).concat(moreFiles)) + })) + ); + }); + + it('renders only a single file when multiple is not true', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + const files = [{ name: 'file1', size: 100 }, { name: 'file2', size: 200 }]; + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect( + baseAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles([files[0] as any]) + })) + ); + const moreFiles = [{ name: 'file3', size: 300 }, { name: 'file4', size: 400 }]; + r.property(WrappedFileUploadInput, 'onValue', moreFiles as any); + r.expect( + baseAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles([moreFiles[0] as any]) + })) + ); + }); + + it('validates files on maxSize', function() { + const maxSize = 500; + const onValue = sinon.spy(); + const r = renderer(function() { + return ; + }); + const multipleAssertion = baseAssertion.setProperty( + WrappedFileUploadInput, + 'multiple', + true + ); + + r.expect(multipleAssertion); + + const files = [ + { name: 'file1', size: 100 }, + { name: 'file2', size: 0 }, + { name: 'file3', size: 499 }, + { name: 'file4', size: 500 }, + { name: 'file5', size: 501 }, + { name: 'file6', size: Math.pow(10, 10) } + ]; + const expectedFiles = (files as any).map(function(file: any) { + const expectedFile = { ...file, message: '', valid: true }; + if (file.size > maxSize) { + expectedFile.valid = false; + expectedFile.message = messages.invalidFileSize; + } + + return expectedFile; + }); + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect( + multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles(expectedFiles) + })) + ); + assert.deepEqual(onValue.firstCall.args[0], expectedFiles); + }); + + it('formats bytes up to PB', function() { + assert.strictEqual(formatBytes(0), '0 B'); + assert.strictEqual(formatBytes(1), '1 B'); + assert.strictEqual(formatBytes(1023), '1023 B'); + assert.strictEqual(formatBytes(1024), '1.00 KB'); + assert.strictEqual(formatBytes(1025), '1.00 KB'); + assert.strictEqual(formatBytes(1034), '1.01 KB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2) - 1), '1023.99 KB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2)), '1.00 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2) + 1), '1.00 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2) + 10485), '1.01 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3) - 1), '1023.99 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3)), '1.00 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3) + 1), '1.00 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3) + 10737418), '1.01 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4) - 1), '1023.99 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4)), '1.00 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4) + 1), '1.00 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4) + 10995116277), '1.01 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5) - 1), '1023.99 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5)), '1.00 PB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5) + 1), '1.00 PB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5) + 11258999068426), '1.01 PB'); + assert.strictEqual(formatBytes(Math.pow(1024, 6) - 1), '1024.00 PB'); + assert.strictEqual(formatBytes(Math.pow(1024, 7)), '1048576.00 PB'); + }); +}); diff --git a/src/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css new file mode 100644 index 0000000000..02c43368b5 --- /dev/null +++ b/src/theme/default/file-upload-input.m.css @@ -0,0 +1,17 @@ +/* The root class for FileUploadInput */ +.root { + border: 2px dashed transparent; +} + +/* Applied to the root node if the widget is disabled */ +.disabled { + cursor: no-drop; +} + +/* The node containing the button and dnd label */ +.wrapper { +} + +/* Applied to the root of the label if provided */ +.labelRoot { +} diff --git a/src/theme/default/file-upload-input.m.css.d.ts b/src/theme/default/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..c6bb275c59 --- /dev/null +++ b/src/theme/default/file-upload-input.m.css.d.ts @@ -0,0 +1,4 @@ +export const root: string; +export const disabled: string; +export const wrapper: string; +export const labelRoot: string; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css new file mode 100644 index 0000000000..fa7df4bea2 --- /dev/null +++ b/src/theme/default/file-uploader.m.css @@ -0,0 +1,51 @@ +/* The root class for FileUploader */ +.root { +} + +/* Applied to the root node if the widget is disabled */ +.disabled { +} + +/* Container for each item representing an added file */ +.fileItem { + display: flex; + flex-direction: column; +} + +/* Applied to a file item if it is invalid */ +.invalid { +} + +/* The file information node within a file item node */ +.fileInfo { + display: flex; + flex-direction: row; +} + +/* The name of each added file */ +.fileItemName { + flex-grow: 1; +} + +/* The size of each added file */ +.fileItemSize { + flex-basis: 8em; + text-align: right; +} + +/* Close icon button rendered for each file */ +.closeButton { + border: none; + background: transparent; + cursor: pointer; + padding-top: 2px; +} + +/* Applied to the node containing the validation message */ +.validationMessage { + color: var(--success-color); +} + +.invalid .validationMessage { + color: var(--error-color); +} diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts new file mode 100644 index 0000000000..fda39b1629 --- /dev/null +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -0,0 +1,9 @@ +export const root: string; +export const disabled: string; +export const fileItem: string; +export const invalid: string; +export const fileInfo: string; +export const fileItemName: string; +export const fileItemSize: string; +export const closeButton: string; +export const validationMessage: string; diff --git a/src/theme/default/index.ts b/src/theme/default/index.ts index 780ccf2d80..0e2c75ccef 100644 --- a/src/theme/default/index.ts +++ b/src/theme/default/index.ts @@ -58,6 +58,8 @@ import * as tooltip from './tooltip.m.css'; import * as twoColumnLayout from './two-column-layout.m.css'; import * as typeahead from './typeahead.m.css'; import * as defaultVariant from './variants/default.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; +import * as fileUploader from './file-uploader.m.css'; export default { theme: { @@ -74,6 +76,8 @@ export default { '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, '@dojo/widgets/email-input': emailInput, + '@dojo/widgets/file-upload-input': fileUploadInput, + '@dojo/widgets/file-uploader': fileUploader, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody, diff --git a/src/theme/dojo/file-upload-input.m.css b/src/theme/dojo/file-upload-input.m.css new file mode 100644 index 0000000000..dce41e8ba2 --- /dev/null +++ b/src/theme/dojo/file-upload-input.m.css @@ -0,0 +1,8 @@ +.root { + background-color: var(--color-background); + border: 2px dashed transparent; +} + +.disabled { + cursor: no-drop; +} diff --git a/src/theme/dojo/file-upload-input.m.css.d.ts b/src/theme/dojo/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..4637162d48 --- /dev/null +++ b/src/theme/dojo/file-upload-input.m.css.d.ts @@ -0,0 +1,2 @@ +export const root: string; +export const disabled: string; diff --git a/src/theme/dojo/file-uploader.m.css b/src/theme/dojo/file-uploader.m.css new file mode 100644 index 0000000000..eedd8e732f --- /dev/null +++ b/src/theme/dojo/file-uploader.m.css @@ -0,0 +1,44 @@ +.root { + background-color: var(--color-background); +} + +.disabled { + cursor: no-drop; +} + +.fileItem { + color: var(--color-text-primary); + display: flex; + flex-direction: column; + line-height: var(--line-height-base); + padding: calc(var(--spacing-regular) / 2); +} + +.fileItem:hover { + background-color: var(--color-background-faded); +} + +.fileInfo { + display: flex; + flex-direction: row; +} + +.fileItemName { + flex-grow: 1; +} + +.closeButton { + border: none; + background: transparent; + color: var(--color-text-primary); + cursor: pointer; + padding-top: 2px; +} + +.validationMessage { + color: var(--color-success); +} + +.invalid .validationMessage { + color: var(--color-error); +} diff --git a/src/theme/dojo/file-uploader.m.css.d.ts b/src/theme/dojo/file-uploader.m.css.d.ts new file mode 100644 index 0000000000..b5de0806cb --- /dev/null +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -0,0 +1,8 @@ +export const root: string; +export const disabled: string; +export const fileItem: string; +export const fileInfo: string; +export const fileItemName: string; +export const closeButton: string; +export const validationMessage: string; +export const invalid: string; diff --git a/src/theme/dojo/index.ts b/src/theme/dojo/index.ts index 2ad293ad17..48e4f5ed64 100644 --- a/src/theme/dojo/index.ts +++ b/src/theme/dojo/index.ts @@ -61,6 +61,8 @@ import * as twoColumnLayout from './two-column-layout.m.css'; import * as typeahead from './typeahead.m.css'; import * as darkVariant from './variants/dark.m.css'; import * as defaultVariant from './variants/default.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; +import * as fileUploader from './file-uploader.m.css'; export default { theme: { @@ -75,6 +77,8 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@dojo/widgets/file-upload-input': fileUploadInput, + '@dojo/widgets/file-uploader': fileUploader, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody, diff --git a/src/theme/material/file-upload-input.m.css b/src/theme/material/file-upload-input.m.css new file mode 100644 index 0000000000..d12f4172cf --- /dev/null +++ b/src/theme/material/file-upload-input.m.css @@ -0,0 +1,12 @@ +.root { + background-color: var(--mdc-theme-background); + border: 2px dashed transparent; +} + +.disabled { + cursor: no-drop; +} + +.labelRoot { + position: static; +} diff --git a/src/theme/material/file-upload-input.m.css.d.ts b/src/theme/material/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..7d552792ac --- /dev/null +++ b/src/theme/material/file-upload-input.m.css.d.ts @@ -0,0 +1,3 @@ +export const root: string; +export const disabled: string; +export const labelRoot: string; diff --git a/src/theme/material/file-uploader.m.css b/src/theme/material/file-uploader.m.css new file mode 100644 index 0000000000..c5d0b7f1cf --- /dev/null +++ b/src/theme/material/file-uploader.m.css @@ -0,0 +1,39 @@ +.root { + background-color: var(--mdc-theme-background); +} + +.disabled { + cursor: no-drop; +} + +.fileItem { + color: var(--mdc-text-color); + display: flex; + flex-direction: column; + padding: calc(var(--mdc-theme-grid-base) / 2); +} + +.fileInfo { + display: flex; + flex-direction: row; +} + +.fileItemName { + flex-grow: 1; +} + +.closeButton { + border: none; + background: transparent; + color: var(--mdc-text-color); + cursor: pointer; + padding-top: 2px; +} + +.validationMessage { + color: var(--mdc-secondary-text-color); +} + +.invalid .validationMessage { + color: var(--mdc-theme-error); +} diff --git a/src/theme/material/file-uploader.m.css.d.ts b/src/theme/material/file-uploader.m.css.d.ts new file mode 100644 index 0000000000..b5de0806cb --- /dev/null +++ b/src/theme/material/file-uploader.m.css.d.ts @@ -0,0 +1,8 @@ +export const root: string; +export const disabled: string; +export const fileItem: string; +export const fileInfo: string; +export const fileItemName: string; +export const closeButton: string; +export const validationMessage: string; +export const invalid: string; diff --git a/src/theme/material/index.ts b/src/theme/material/index.ts index f30767e268..de3a33d448 100644 --- a/src/theme/material/index.ts +++ b/src/theme/material/index.ts @@ -61,6 +61,8 @@ import * as twoColumnLayout from './two-column-layout.m.css'; import * as typeahead from './typeahead.m.css'; import * as defaultVariant from './variants/default.m.css'; import * as darkVariant from './variants/dark.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; +import * as fileUploader from './file-uploader.m.css'; export default { theme: { @@ -75,6 +77,8 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@dojo/widgets/file-upload-input': fileUploadInput, + '@dojo/widgets/file-uploader': fileUploader, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody,