Skip to content

Commit

Permalink
Merge e157952 into bc2b900
Browse files Browse the repository at this point in the history
  • Loading branch information
Max Einstein authored May 3, 2018
2 parents bc2b900 + e157952 commit 128d0ad
Show file tree
Hide file tree
Showing 10 changed files with 354 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/examples/form/FilePicker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A wrapper around the native file input that checks for extensions and file size. Returns a file object.
1 change: 1 addition & 0 deletions docs/examples/form/ImagePicker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A wrapper around the native file input that enforces extensions and file size, as well as specific dimensions. Returns a file object by default - or the base64 encoded string if specified otherwise.
39 changes: 39 additions & 0 deletions packages/quark-web/src/components/form/FilePicker/FileInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @flow
// external
import * as React from 'react'
import { View } from 'react-native-web'

type Props = {
children: React.Node,
onChange: File => void,
style: {}
}

class FileInput extends React.Component<Props> {
fileInput: ?HTMLInputElement

_handleUpload = (evt: Event) => {
const file = evt.target.files[0]
this.props.onChange(file)
// free up the fileInput again
this.fileInput.value = null
}

render() {
return (
<View style={this.props.style}>
<input
type="file"
style={{ display: 'none' }}
onChange={this._handleUpload}
ref={ele => (this.fileInput = ele)}
/>
{React.cloneElement(this.props.children, {
onClick: () => this.fileInput.click()
})}
</View>
)
}
}

export default FileInput
65 changes: 65 additions & 0 deletions packages/quark-web/src/components/form/FilePicker/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @flow
// external
import * as React from 'react'
// local
import FileInput from './FileInput'

type Props = {
children: React.Node,
onChange: File => void,
onError: string => void,
maxSize: number,
extensions?: Array<string>,
style?: {}
}

class FilePicker extends React.Component<Props> {
static defaultProps = {
maxSize: 2
}

_validate = (file: File) => {
const { onError, onChange, maxSize, extensions } = this.props

// make sure a file was provided in the first place
if (!file) {
onError('Failed to upload a file.')
return
}

// if we care about file extensions
if (extensions) {
const uploadedFileExt = file.name
.split('.')
.pop()
.toLowerCase()
const isValidFileExt = extensions
.map(ext => ext.toLowerCase())
.includes(uploadedFileExt)

if (!isValidFileExt) {
onError(`Must upload a file of type: ${extensions.join(' or ')}`)
return
}
}

// convert maxSize from megabytes to bytes
const maxBytes = maxSize * 1000000

if (file.size > maxBytes) {
onError(`File size must be less than ${maxSize} MB.`)
return
}

// return native file object
onChange(file)
}

render = () => (
<FileInput onChange={this._validate} style={this.props.style}>
{this.props.children}
</FileInput>
)
}

export default FilePicker
98 changes: 98 additions & 0 deletions packages/quark-web/src/components/form/FilePicker/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// external
import React from 'react'
import { mount } from 'enzyme'
// local
import FilePicker from '.'

describe('File Picker', () => {
let onChange
let onError

beforeEach(() => {
onChange = jest.fn()
onError = jest.fn()
})

test('returns a valid component with required props', () => {
const ele = (
<FilePicker onChange={() => ({})} onError={() => ({})}>
<button>Click to upload</button>
</FilePicker>
)

expect(React.isValidElement(ele)).toBe(true)
})

test('call error handler when no file uploaded', () => {
// mount the select with a few options
const wrapper = mount(
<FilePicker onChange={onChange} onError={onError}>
<div>Click here</div>
</FilePicker>
)

// trigger the onChange callback on file input
wrapper.find('input').simulate('change', { target: { files: [] } })

expect(onError.mock.calls.length).toBe(1)
expect(onChange.mock.calls.length).toBe(0)
})

test('call error handler when a file with incorrect extension is uploaded', () => {
// mount the select with a few options
const wrapper = mount(
<FilePicker onChange={onChange} onError={onError} extensions={['md']}>
<div>Click here</div>
</FilePicker>
)

const file = new Blob(['file contents'], { type: 'text/plain' })
file.name = 'file.txt'

// trigger the onChange callback on file input
wrapper.find('input').simulate('change', { target: { files: [file] } })

expect(onError.mock.calls.length).toBe(1)
expect(onChange.mock.calls.length).toBe(0)
})

test('call error handler when a file that is too large is uploaded', () => {
// mount the select with a few options
const wrapper = mount(
<FilePicker
onChange={onChange}
onError={onError}
// set unreasonably small max size so that our tiny blob is too big
maxSize={0.0000000001}
>
<div>Click here</div>
</FilePicker>
)

const file = new Blob(['file contents'], { type: 'text/plain' })

// trigger the onChange callback on file input
wrapper.find('input').simulate('change', { target: { files: [file] } })

expect(onError.mock.calls.length).toBe(1)
expect(onChange.mock.calls.length).toBe(0)
})

test('call change handler when a file with correct size and extension is uploaded', () => {
// mount the select with a few options
const wrapper = mount(
<FilePicker onChange={onChange} onError={onError} extensions={['txt']} maxSize={1}>
<div>Click here</div>
</FilePicker>
)

const file = new Blob(['file contents'], { type: 'text/plain' })
file.name = 'file.txt'

// trigger the onChange callback on file input
wrapper.find('input').simulate('change', { target: { files: [file] } })

expect(onError.mock.calls.length).toBe(0)
expect(onChange.mock.calls.length).toBe(1)
})
})
61 changes: 61 additions & 0 deletions packages/quark-web/src/components/form/ImagePicker/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @flow
// external
import * as React from 'react'
// local
import loadFile from './load-file'
import loadImage from './load-image'
import { FilePicker } from '..'

type Props = {
children: React.Node,
onChange: File => File | string,
onError: string => void,
dims: {
minWidth: number,
maxWidth: number,
minHeight: number,
maxHeight: number
},
base64?: boolean,
maxSize?: number,
extensions?: Array<string>,
style?: {}
}

class UploadImage extends React.Component<Props> {
static defaultProps = {
base64: false
}

_handleImg = async (file: File) => {
// grab used props
const { onChange, onError, dims, base64 } = this.props

try {
const dataUrl = await loadFile(file)
await loadImage(dataUrl, dims)
if (base64) {
return dataUrl
}
onChange(file)
} catch (err) {
// pass err message to onError handler
onError(err.message)
}
}

render() {
const { children, ...unused } = this.props
// pass our own onChange handler here and
// use the user-provided onChange handler above in _handleImg
Reflect.deleteProperty(unused, 'onChange')

return (
<FilePicker onChange={this._handleImg} {...unused}>
{children}
</FilePicker>
)
}
}

export default UploadImage
11 changes: 11 additions & 0 deletions packages/quark-web/src/components/form/ImagePicker/load-file.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function loadFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()

reader.readAsDataURL(file)

reader.onloadend = loadedFile => resolve(loadedFile.target.result)

reader.onerror = () => reject(new Error('There was an error uploading the file'))
})
}
35 changes: 35 additions & 0 deletions packages/quark-web/src/components/form/ImagePicker/load-image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export default function loadImg(dataUrl, dims) {
// destructure props from dims
const { minWidth, maxWidth, minHeight, maxHeight } = dims

return new Promise((resolve, reject) => {
// create a new html image element
const img = new Image()
// set the image src attribute to our dataUrl
img.src = dataUrl

// listen for onload event
img.onload = () => {
// validate the min and max image dimensions
if (img.width < minWidth || img.height < minHeight) {
reject(
new Error(
`The uploaded image is too small. Must be at least ${minWidth}px by ${minHeight}px.`
)
)
}

if (img.width > maxWidth || img.height > maxHeight) {
reject(
new Error(
`The uploaded image is too large. Must be no more than ${maxWidth}px by ${maxHeight}px.`
)
)
}

resolve(true)
}

img.onerror = () => reject(new Error('There was an error uploading the image'))
})
}
41 changes: 41 additions & 0 deletions packages/quark-web/src/components/form/ImagePicker/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// external
import React from 'react'
import { mount } from 'enzyme'
// local
import ImagePicker from '.'

describe('Image File Input', () => {
let onChange
let onError
const dims = { minWidth: 0, maxWidth: 10, minHeight: 0, maxHeight: 10 }

beforeEach(() => {
onChange = jest.fn()
onError = jest.fn()
})

test('returns a valid component with required props', () => {
const ele = (
<ImagePicker onChange={() => ({})} onError={() => ({})} dims={dims}>
<button>Click to upload</button>
</ImagePicker>
)

expect(React.isValidElement(ele)).toBe(true)
})

test('call the error handler when no image uploaded', () => {
// mount the select with a few options
const wrapper = mount(
<ImagePicker onChange={onChange} onError={onError} dims={dims}>
<div>Click here</div>
</ImagePicker>
)

// trigger the onChange callback on file input
wrapper.find('input').simulate('change', { target: { files: [] } })

expect(onError.mock.calls.length).toBe(1)
expect(onChange.mock.calls.length).toBe(0)
})
})
2 changes: 2 additions & 0 deletions packages/quark-web/src/components/form/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export { default as Option } from './Option'
export { default as Select } from './Select'
export { default as Dropdown } from './Dropdown'
export { default as TextInput } from './TextInput'
export { default as FilePicker } from './FilePicker'
export { default as ImagePicker } from './ImagePicker'

0 comments on commit 128d0ad

Please sign in to comment.