implement drag and drop upload

closes CNVS-11668

test plan:
  - enable new files UI feature flag
  - in the new files interface:
    - drag a file from your OS to the folder area in the canvas UI
    - UI should update to reflect a dropable zone
    - drop the file
    - UI should return to normal state
    - previous upload process should take over

Change-Id: Ifbf66eec86f431c5d29a50b3e6e05e21b20bc410
Reviewed-on: https://gerrit.instructure.com/41670
Reviewed-by: Ryan Shaw <ryan@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Clare Strong <clare@instructure.com>
Product-Review: Jason Madsen <jmadsen@instructure.com>
This commit is contained in:
Jason Madsen 2014-09-23 16:50:10 -06:00
parent bf2d25d962
commit 596848acc0
11 changed files with 283 additions and 60 deletions

View File

@ -1,11 +1,10 @@
define [
'i18n!current_uploads'
'react'
'compiled/react/shared/utils/withReactDOM'
'../modules/UploadQueue'
'./UploadProgress'
'compiled/jquery.rails_flash_notifications'
], (I18n, React, withReactDOM, UploadQueue, UploadProgress) ->
], (React, withReactDOM, UploadQueue, UploadProgress) ->
CurrentUploads = React.createClass
displayName: 'CurrentUploads'
@ -35,23 +34,6 @@ define [
percent = currentUploader.roundProgress()
$.screenReaderFlashMessage "#{name} - #{percent}%"
buildInstructions: ->
div className: 'current_uploads__instructions',
a
role: 'button'
'aria-label': I18n.t('close', 'close')
onClick: @handleCloseClick
className: 'current_uploads__instructions__close',
'\u2A09'
i className: 'icon-upload current_uploads__instructions__icon-upload'
div {},
p className: 'current_uploads__instructions__drag',
I18n.t('drag_files_here', 'Drag Folders and Files here')
a
role: 'button'
onClick: @handleBrowseClick,
I18n.t('click_to_browse', 'or click to browse your computer')
shouldDisplay: ->
!!@state.isOpen || @state.currentUploads.length
@ -65,7 +47,7 @@ define [
if @state.currentUploads.length
@buildProgressViews()
else if !!@state.isOpen
@buildInstructions()
div {}, ''
render: withReactDOM ->
divName = ''

View File

@ -29,11 +29,24 @@ define [
handleBackClick: ->
@setState isEditing: false
# pass back expandZip to preserve options that was possibly already made
# in a previous modal
handleReplaceClick: ->
@props.onNameConflictResolved({file: @state.fileOptions.file, dup: 'overwrite'})
@props.onNameConflictResolved({
file: @state.fileOptions.file
dup: 'overwrite'
expandZip: @state.fileOptions.expandZip
})
# pass back expandZip to preserve options that was possibly already made
# in a previous modal
handleChangeClick: ->
@props.onNameConflictResolved({file: @state.fileOptions.file, dup: 'rename', name: @refs.newName.getDOMNode().value})
@props.onNameConflictResolved({
file: @state.fileOptions.file
dup: 'rename'
name: @refs.newName.getDOMNode().value
expandZip: @state.fileOptions.expandZip
})
handleFormSubmit: (e) ->
e.preventDefault()

View File

@ -11,7 +11,8 @@ define [
'../utils/updateAPIQuerySortParams'
'compiled/models/Folder'
'./CurrentUploads'
], (_, React, I18n, withReactDOM, filesEnv, ColumnHeaders, LoadingIndicator, FolderChild, getAllPages, updateAPIQuerySortParams, Folder, CurrentUploads) ->
'./UploadDropZone'
], (_, React, I18n, withReactDOM, filesEnv, ColumnHeaders, LoadingIndicator, FolderChild, getAllPages, updateAPIQuerySortParams, Folder, CurrentUploads, UploadDropZone) ->
LEADING_SLASH_TILL_BUT_NOT_INCLUDING_NEXT_SLASH = /^\/[^\/]*/
@ -79,6 +80,7 @@ define [
role: 'grid'
'aria-label': I18n.t('main_file_browser_pane', 'Main file browser pane')
},
UploadDropZone(currentFolder: @props.currentFolder)
CurrentUploads({})
ColumnHeaders {
to: (if @props.params.splat then 'folder' else 'rootFolder')

View File

@ -9,6 +9,8 @@ define [
'../modules/FileOptionsCollection'
], (I18n, React, withReactDOM, _, FileRenameForm, customPropTypes, ZipFileOptionsForm, FileOptionsCollection) ->
resolvedUserAction = false
UploadButton = React.createClass
displayName: 'UploadButton'
@ -28,6 +30,7 @@ define [
this.refs.addFileInput.getDOMNode().click()
handleFilesInputChange: (e) ->
resolvedUserAction = false
files = this.refs.addFileInput.getDOMNode().files
FileOptionsCollection.setFolder(@props.currentFolder)
FileOptionsCollection.setOptionsFromFiles(files)
@ -35,18 +38,50 @@ define [
onNameConflictResolved: (fileNameOptions) ->
FileOptionsCollection.onNameConflictResolved(fileNameOptions)
resolvedUserAction = true
@setState(FileOptionsCollection.getState())
onZipOptionsResolved: (fileNameOptions) ->
FileOptionsCollection.onZipOptionsResolved(fileNameOptions)
resolvedUserAction = true
@setState(FileOptionsCollection.getState())
onClose: ->
@refs.form.getDOMNode().reset()
if !resolvedUserAction
# user dismissed zip or name conflict modal without resolving things
# reset state to dump previously selected files
FileOptionsCollection.resetState()
@setState(FileOptionsCollection.getState())
resolvedUserAction = false
componentDidUpdate: (prevState) ->
if @state.nameCollisions.length == 0 && @state.resolvedNames.length > 0 && FileOptionsCollection.hasNewOptions()
@queueUploads()
else
resolvedUserAction = false
componentWillMount: ->
FileOptionsCollection.onChange = @setStateFromOptions
componentWillUnMount: ->
FileOptionsCollection.onChange = null
setStateFromOptions: ->
@setState(FileOptionsCollection.getState())
buildPotentialModal: ->
if @state.zipOptions.length
ZipFileOptionsForm
fileOptions: @state.zipOptions[0]
onZipOptionsResolved: @onZipOptionsResolved
onClose: @onClose
else if @state.nameCollisions.length
FileRenameForm
fileOptions: @state.nameCollisions[0]
onNameConflictResolved: @onNameConflictResolved
onClose: @onClose
render: withReactDOM ->
span {},
@ -65,11 +100,4 @@ define [
i className: 'icon-upload'
span className: ('hidden-phone' if @props.showingButtons),
I18n.t('upload', 'Upload')
FileRenameForm
fileOptions: @state.nameCollisions[0]
onNameConflictResolved: @onNameConflictResolved
onClose: @onClose
ZipFileOptionsForm
fileOptions: @state.zipOptions[0]
onZipOptionsResolved: @onZipOptionsResolved
onClose: @onClose
@buildPotentialModal()

View File

@ -0,0 +1,108 @@
define [
'i18n!upload_drop_zone'
'react'
'compiled/react/shared/utils/withReactDOM'
'../modules/FileOptionsCollection'
'compiled/models/Folder'
], (I18n, React, withReactDOM, FileOptionsCollection, Folder) ->
UploadDropZone = React.createClass
displayName: 'UploadDropZone'
propTypes:
currentFolder: React.PropTypes.instanceOf(Folder)
getInitialState: ->
active: false
componentDidMount: ->
@getParent().addEventListener('dragenter', @onParentDragEnter)
document.addEventListener('dragenter', @killWindowDropDisplay)
document.addEventListener('dragover', @killWindowDropDisplay)
document.addEventListener('drop', @killWindowDrop)
componentWillUnmount: ->
@getParent().removeEventListener('dragenter', @onParentDragEnter)
document.removeEventListener('dragenter', @killWindowDropDisplay)
document.removeEventListener('dragover', @killWindowDropDisplay)
document.removeEventListener('drop', @killWindowDrop)
onDragEnter: (e) ->
if @shouldAcceptDrop(e.dataTransfer.types)
if !this.state.active
@setState({active: true})
e.dataTransfer.dropEffect = 'copy'
e.preventDefault()
e.stopPropagation() # keep event from getting to document
false
else
true
onDragLeave: (e) ->
@setState({active: false})
onDrop: (e) ->
FileOptionsCollection.setFolder(@props.currentFolder)
FileOptionsCollection.setOptionsFromFiles(e.dataTransfer.files, true)
@setState({active: false})
e.preventDefault()
e.stopPropagation()
false
# when you drag a file over the parent, make drop zone active
# remainder of drag-n-drop events happen on dropzone
onParentDragEnter: (e)->
if @shouldAcceptDrop(e.dataTransfer.types)
if !this.state.active
@setState({active: true})
killWindowDropDisplay: (e) ->
if e.target != @getParent()
e.preventDefault()
killWindowDrop: (e) ->
e.preventDefault()
shouldAcceptDrop: (types) ->
# event.dataTransfer.types doesn't respond to array methods in Firefox like
# the other browsers even though it has length, and array access?
i = 0
found = false
while i<types.length
type = types[i]
found = (type == 'Files')
if (found)
break
i++
found
getParent: ->
@getDOMNode().parentElement
buildNonActiveDropZone: ->
div
className: 'UploadDropZone',
''
buildInstructions: ->
div className: 'UploadDropZone__instructions',
i className: 'icon-upload UploadDropZone__instructions--icon-upload'
div {},
p className: 'UploadDropZone__instructions--drag',
I18n.t('drop_to_upload', 'Drop items to upload')
buildDropZone: ->
div
className: 'UploadDropZone UploadDropZone__active'
onDrop: this.onDrop
onDragLeave: this.onDragLeave
onDragOver: this.onDragEnter
onDragEnter: this.onDragEnter,
@buildInstructions()
render: withReactDOM ->
if @state.active
@buildDropZone()
else
@buildNonActiveDropZone()

View File

@ -9,7 +9,10 @@ define [
displayName: 'UploadProgress'
propTypes:
uploader: React.PropTypes.instanceOf(FileUploader).isRequired
uploader: React.PropTypes.shape({
getFileName: React.PropTypes.func.isRequired
roundProgress: React.PropTypes.func.inRequired
})
getLabel: withReactDOM ->
span {},

View File

@ -55,11 +55,11 @@ define [
while i < selectedFiles.length
fileOptions = selectedFiles[i]
nameToTest = fileOptions.name || fileOptions.file.name
# only mark as collision if it is a collision that hasn't been resolved, or is is a zip that will be expanded
if @fileNameExists(nameToTest) && (fileOptions.dup != 'overwrite' && (!fileOptions.expandZip? || fileOptions.expandZip == false))
collisions.push fileOptions
else if (@isZipFile(fileOptions.file) && fileOptions.expandZip == undefined)
if (@isZipFile(fileOptions.file) && fileOptions.expandZip == undefined)
zips.push fileOptions
# only mark as collision if it is a collision that hasn't been resolved, or is is a zip that will be expanded
else if @fileNameExists(nameToTest) && (fileOptions.dup != 'overwrite' && (!fileOptions.expandZip? || fileOptions.expandZip == false))
collisions.push fileOptions
else
resolved.push fileOptions
i++
@ -101,10 +101,12 @@ define [
{resolved, collisions, zips} = @segregateOptionBuckets(allOptions)
@setState({nameCollisions: collisions, resolvedNames: resolved, zipOptions: zips})
setOptionsFromFiles: (files) ->
setOptionsFromFiles: (files, notifyChange) ->
allOptions = @toFilesOptionArray(files)
{resolved, collisions, zips} = @segregateOptionBuckets(allOptions)
@setState({nameCollisions: collisions, resolvedNames: resolved, zipOptions: zips, newOptions: true})
if notifyChange && @onChange
@onChange()
hasNewOptions: ->
return @state.newOptions
@ -124,4 +126,7 @@ define [
resetState: ->
@state = @buildDefaultState()
onChange: ->
#noop
new FileOptionsCollection()

View File

@ -32,7 +32,7 @@ define [
@_actualUpload()
onUploadPosted: (uploadResults) =>
if (event.target.status >= 400)
if (uploadResults.target && uploadResults.target.status >= 400)
@deferred.reject()
return
@ -41,7 +41,7 @@ define [
$.getJSON(url).then (results) =>
@getContentMigration()
else
results = $.parseJSON(event.target.response)
results = $.parseJSON(uploadResults.target.response)
@getContentMigration()
# get the content migration when ready and use progress api to pull migration progress

View File

@ -93,6 +93,7 @@ $ef-thumbnail-size: 36px;
}
}
.ef-directory{
position: relative;
margin: 10px;
flex: 3 3 0;
display: flex;
@ -246,27 +247,6 @@ $ef-thumbnail-size: 36px;
padding-left: 20px;
padding-right: 20px;
}
.current_uploads__instructions {
text-align: center;
}
.current_uploads__instructions__close {
float: right;
color: $canvas-neutral;
font-size: -1.8em;
pointer: cursor;
}
i[class*=current_uploads__instructions__icon-upload]:before {
font-size: 100px;
}
i[class*=current_uploads__instructions__icon-upload] {
color: $canvas-primary;
width: 100px;
height: 100px;
}
.current_uploads__instructions__drag {
font-size: 1.8em;
font-weight: bold;
}
.ef-breadcrumb-popover {
display: block;
@ -289,7 +269,44 @@ i[class*=current_uploads__instructions__icon-upload] {
}
}
.UploadDropZone {
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
background: rgba(254, 254, 254, 0);
z-index: -1;
}
.UploadDropZone__active {
background: rgba(254, 254, 254, 0.9);
z-index: 1;
}
.UploadDropZone__instructions {
margin-top: 70px;
text-align: center;
pointer-events: none;
}
.UploadDropZone__instructions__close {
float: right;
color: $canvas-neutral;
font-size: -1.8em;
pointer: cursor;
}
i[class*=UploadDropZone__instructions--icon-upload]:before {
font-size: 100px;
}
i[class*=UploadDropZone__instructions--icon-upload] {
color: $canvas-primary;
width: 100px;
height: 100px;
}
.UploadDropZone__instructions--drag {
font-size: 1.3em;
font-weight: bold;
}

View File

@ -58,3 +58,29 @@ define [
Simulate.click(@form.refs.renameBtn.getDOMNode())
ok(@form.state.isEditing)
Simulate.click(@form.refs.commitChangeBtn.getDOMNode())
test 'onNameConflicResolved preserves expandZip option when renaming', ->
expect(2)
@form.setProps(
fileOptions:
file:
name: 'file_name.md'
expandZip: 'true'
onNameConflictResolved: (options) ->
equal(options.expandZip, 'true')
)
Simulate.click(@form.refs.renameBtn.getDOMNode())
ok(@form.state.isEditing)
Simulate.click(@form.refs.commitChangeBtn.getDOMNode())
test 'onNameConflicResolved preserves expandZip option when replacing', ->
expect(1)
@form.setProps(
fileOptions:
file:
name: 'file_name.md'
expandZip: 'true'
onNameConflictResolved: (options) ->
equal(options.expandZip, 'true')
)
Simulate.click(@form.refs.replaceBtn.getDOMNode())

View File

@ -0,0 +1,39 @@
define [
'react'
'compiled/react_files/components/UploadDropZone'
], (React, UploadDropZone) ->
Simulate = React.addons.TestUtils.Simulate
node = document.querySelector('#fixtures')
module 'UploadDropZone',
setup: ->
@uploadZone = React.renderComponent(UploadDropZone({}), node)
teardown: ->
React.unmountComponentAtNode(node)
test 'displays nothing by default', ->
displayText = @uploadZone.getDOMNode().innerHTML.trim()
equal(displayText, '')
test 'displays dropzone when active', ->
@uploadZone.setState({active: true})
ok(@uploadZone.getDOMNode().querySelector('.UploadDropZone__instructions'))
test 'handles drop event on target', ->
sinon.stub(@uploadZone, 'onDrop')
@uploadZone.setState({active: true})
dataTransfer = {
types: ['Files']
}
n = @uploadZone.getDOMNode()
Simulate.dragEnter(n, {dataTransfer: dataTransfer})
Simulate.dragOver(n, {dataTransfer: dataTransfer})
Simulate.drop(n)
ok(@uploadZone.onDrop.calledOnce, 'handles file drops')
@uploadZone.onDrop.restore()