mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
Add JavaScript direct upload support (#81)
This commit is contained in:
parent
dc235c4d13
commit
6262891b56
16 changed files with 3139 additions and 0 deletions
5
.babelrc
Normal file
5
.babelrc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"presets": [
|
||||
["env", { "modules": false } ]
|
||||
]
|
||||
}
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
.byebug_history
|
||||
/node_modules
|
||||
test/dummy/db/*.sqlite3
|
||||
test/dummy/db/*.sqlite3-journal
|
||||
test/dummy/log/*.log
|
||||
|
|
38
README.md
38
README.md
|
@ -96,6 +96,44 @@ Variation of image attachment:
|
|||
6. Optional: Add `gem "google-cloud-storage", "~> 1.3"` to your Gemfile if you want to use Google Cloud Storage.
|
||||
7. Optional: Add `gem "mini_magick"` to your Gemfile if you want to use variants.
|
||||
|
||||
## Direct uploads
|
||||
|
||||
Active Storage, with its included JavaScript library, supports uploading directly from the client to the cloud.
|
||||
|
||||
### Direct upload installation
|
||||
|
||||
1. Include `activestorage.js` in your application's JavaScript bundle.
|
||||
|
||||
Using the asset pipeline:
|
||||
```js
|
||||
//= require activestorage
|
||||
```
|
||||
Using the npm package:
|
||||
```js
|
||||
import * as ActiveStorage from "activestorage"
|
||||
ActiveStorage.start()
|
||||
```
|
||||
2. Annotate file inputs with the direct upload URL.
|
||||
|
||||
```ruby
|
||||
<%= form.file_field :attachments, multiple: true, data: { direct_upload_url: rails_direct_uploads_url } %>
|
||||
```
|
||||
3. That's it! Uploads begin upon form submission.
|
||||
|
||||
### Direct upload JavaScript events
|
||||
|
||||
| Event name | Event target | Event data (`event.detail`) | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `direct-uploads:start` | `<form>` | None | A form containing files for direct upload fields was submit. |
|
||||
| `direct-upload:initialize` | `<input>` | `{id, file}` | Dispatched for every file after form submission. |
|
||||
| `direct-upload:start` | `<input>` | `{id, file}` | A direct upload is starting. |
|
||||
| `direct-upload:before-blob-request` | `<input>` | `{id, file, xhr}` | Before making a request to your application for direct upload metadata. |
|
||||
| `direct-upload:before-storage-request` | `<input>` | `{id, file, xhr}` | Before making a request to store a file. |
|
||||
| `direct-upload:progress` | `<input>` | `{id, file, progress}` | As requests to store files progress. |
|
||||
| `direct-upload:error` | `<input>` | `{id, file, error}` | An error occurred. An `alert` will display unless this event is canceled. |
|
||||
| `direct-upload:end` | `<input>` | `{id, file}` | A direct upload has ended. |
|
||||
| `direct-uploads:end` | `<form>` | None | All direct uploads have ended. |
|
||||
|
||||
## Compatibility & Expectations
|
||||
|
||||
Active Storage only works with the development version of Rails 5.2+ (as of July 19, 2017). This separate repository is a staging ground for the upcoming inclusion in rails/rails prior to the Rails 5.2 release. It is not intended to be a long-term stand-alone repository.
|
||||
|
|
1
app/assets/javascripts/activestorage.js
Normal file
1
app/assets/javascripts/activestorage.js
Normal file
File diff suppressed because one or more lines are too long
52
app/javascript/activestorage/blob_record.js
Normal file
52
app/javascript/activestorage/blob_record.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { getMetaValue } from "./helpers"
|
||||
|
||||
export class BlobRecord {
|
||||
constructor(file, checksum, url) {
|
||||
this.file = file
|
||||
|
||||
this.attributes = {
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
byte_size: file.size,
|
||||
checksum: checksum
|
||||
}
|
||||
|
||||
this.xhr = new XMLHttpRequest
|
||||
this.xhr.open("POST", url, true)
|
||||
this.xhr.responseType = "json"
|
||||
this.xhr.setRequestHeader("Content-Type", "application/json")
|
||||
this.xhr.setRequestHeader("Accept", "application/json")
|
||||
this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
|
||||
this.xhr.setRequestHeader("X-CSRF-Token", getMetaValue("csrf-token"))
|
||||
this.xhr.addEventListener("load", event => this.requestDidLoad(event))
|
||||
this.xhr.addEventListener("error", event => this.requestDidError(event))
|
||||
}
|
||||
|
||||
create(callback) {
|
||||
this.callback = callback
|
||||
this.xhr.send(JSON.stringify({ blob: this.attributes }))
|
||||
}
|
||||
|
||||
requestDidLoad(event) {
|
||||
const { status, response } = this.xhr
|
||||
if (status >= 200 && status < 300) {
|
||||
this.attributes.signed_id = response.signed_blob_id
|
||||
this.uploadURL = response.upload_to_url
|
||||
this.callback(null, this.toJSON())
|
||||
} else {
|
||||
this.requestDidError(event)
|
||||
}
|
||||
}
|
||||
|
||||
requestDidError(event) {
|
||||
this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.xhr.status}`)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const result = {}
|
||||
for (const key in this.attributes) {
|
||||
result[key] = this.attributes[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
31
app/javascript/activestorage/blob_upload.js
Normal file
31
app/javascript/activestorage/blob_upload.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
export class BlobUpload {
|
||||
constructor(blob) {
|
||||
this.blob = blob
|
||||
this.file = blob.file
|
||||
|
||||
this.xhr = new XMLHttpRequest
|
||||
this.xhr.open("PUT", blob.uploadURL, true)
|
||||
this.xhr.setRequestHeader("Content-Type", blob.attributes.content_type)
|
||||
this.xhr.setRequestHeader("Content-MD5", blob.attributes.checksum)
|
||||
this.xhr.addEventListener("load", event => this.requestDidLoad(event))
|
||||
this.xhr.addEventListener("error", event => this.requestDidError(event))
|
||||
}
|
||||
|
||||
create(callback) {
|
||||
this.callback = callback
|
||||
this.xhr.send(this.file)
|
||||
}
|
||||
|
||||
requestDidLoad(event) {
|
||||
const { status, response } = this.xhr
|
||||
if (status >= 200 && status < 300) {
|
||||
this.callback(null, this.xhr.response)
|
||||
} else {
|
||||
this.requestDidError(event)
|
||||
}
|
||||
}
|
||||
|
||||
requestDidError(event) {
|
||||
this.callback(`Error storing "${this.file.name}". Status: ${this.xhr.status}`)
|
||||
}
|
||||
}
|
43
app/javascript/activestorage/direct_upload.js
Normal file
43
app/javascript/activestorage/direct_upload.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { FileChecksum } from "./file_checksum"
|
||||
import { BlobRecord } from "./blob_record"
|
||||
import { BlobUpload } from "./blob_upload"
|
||||
|
||||
let id = 0
|
||||
|
||||
export class DirectUpload {
|
||||
constructor(file, options = {}) {
|
||||
this.id = id++
|
||||
this.file = file
|
||||
this.url = options.url
|
||||
this.delegate = options.delegate
|
||||
}
|
||||
|
||||
create(callback) {
|
||||
const fileChecksum = new FileChecksum(this.file)
|
||||
fileChecksum.create((error, checksum) => {
|
||||
const blob = new BlobRecord(this.file, checksum, this.url)
|
||||
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)
|
||||
blob.create(error => {
|
||||
if (error) {
|
||||
callback(error)
|
||||
} else {
|
||||
const upload = new BlobUpload(blob)
|
||||
notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr)
|
||||
upload.create(error => {
|
||||
if (error) {
|
||||
callback(error)
|
||||
} else {
|
||||
callback(null, blob.toJSON())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function notify(object, methodName, ...messages) {
|
||||
if (object && typeof object[methodName] == "function") {
|
||||
return object[methodName](...messages)
|
||||
}
|
||||
}
|
67
app/javascript/activestorage/direct_upload_controller.js
Normal file
67
app/javascript/activestorage/direct_upload_controller.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { DirectUpload } from "./direct_upload"
|
||||
import { dispatchEvent } from "./helpers"
|
||||
|
||||
export class DirectUploadController {
|
||||
constructor(input, file) {
|
||||
this.input = input
|
||||
this.file = file
|
||||
this.directUpload = new DirectUpload(this.file, { url: this.url, delegate: this })
|
||||
this.dispatch("initialize")
|
||||
}
|
||||
|
||||
start(callback) {
|
||||
const hiddenInput = document.createElement("input")
|
||||
hiddenInput.type = "hidden"
|
||||
hiddenInput.name = this.input.name
|
||||
this.input.insertAdjacentElement("beforebegin", hiddenInput)
|
||||
|
||||
this.dispatch("start")
|
||||
|
||||
this.directUpload.create((error, attributes) => {
|
||||
if (error) {
|
||||
hiddenInput.parentNode.removeChild(hiddenInput)
|
||||
this.dispatchError(error)
|
||||
} else {
|
||||
hiddenInput.value = attributes.signed_id
|
||||
}
|
||||
|
||||
this.dispatch("end")
|
||||
callback(error)
|
||||
})
|
||||
}
|
||||
|
||||
uploadRequestDidProgress(event) {
|
||||
const progress = event.loaded / event.total * 100
|
||||
if (progress) {
|
||||
this.dispatch("progress", { progress })
|
||||
}
|
||||
}
|
||||
|
||||
get url() {
|
||||
return this.input.getAttribute("data-direct-upload-url")
|
||||
}
|
||||
|
||||
dispatch(name, detail = {}) {
|
||||
detail.file = this.file
|
||||
detail.id = this.directUpload.id
|
||||
return dispatchEvent(this.input, `direct-upload:${name}`, { detail })
|
||||
}
|
||||
|
||||
dispatchError(error) {
|
||||
const event = this.dispatch("error", { error })
|
||||
if (!event.defaultPrevented) {
|
||||
alert(error)
|
||||
}
|
||||
}
|
||||
|
||||
// DirectUpload delegate
|
||||
|
||||
directUploadWillCreateBlobWithXHR(xhr) {
|
||||
this.dispatch("before-blob-request", { xhr })
|
||||
}
|
||||
|
||||
directUploadWillStoreFileWithXHR(xhr) {
|
||||
this.dispatch("before-storage-request", { xhr })
|
||||
xhr.upload.addEventListener("progress", event => this.uploadRequestDidProgress(event))
|
||||
}
|
||||
}
|
50
app/javascript/activestorage/direct_uploads_controller.js
Normal file
50
app/javascript/activestorage/direct_uploads_controller.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { DirectUploadController } from "./direct_upload_controller"
|
||||
import { findElements, dispatchEvent, toArray } from "./helpers"
|
||||
|
||||
const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"
|
||||
|
||||
export class DirectUploadsController {
|
||||
constructor(form) {
|
||||
this.form = form
|
||||
this.inputs = findElements(form, inputSelector).filter(input => input.files.length)
|
||||
}
|
||||
|
||||
start(callback) {
|
||||
const controllers = this.createDirectUploadControllers()
|
||||
|
||||
const startNextController = () => {
|
||||
const controller = controllers.shift()
|
||||
if (controller) {
|
||||
controller.start(error => {
|
||||
if (error) {
|
||||
callback(error)
|
||||
this.dispatch("end")
|
||||
} else {
|
||||
startNextController()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
callback()
|
||||
this.dispatch("end")
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatch("start")
|
||||
startNextController()
|
||||
}
|
||||
|
||||
createDirectUploadControllers() {
|
||||
const controllers = []
|
||||
this.inputs.forEach(input => {
|
||||
toArray(input.files).forEach(file => {
|
||||
const controller = new DirectUploadController(input, file)
|
||||
controllers.push(controller)
|
||||
})
|
||||
})
|
||||
return controllers
|
||||
}
|
||||
|
||||
dispatch(name, detail = {}) {
|
||||
return dispatchEvent(this.form, `direct-uploads:${name}`, { detail })
|
||||
}
|
||||
}
|
48
app/javascript/activestorage/file_checksum.js
Normal file
48
app/javascript/activestorage/file_checksum.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
import SparkMD5 from "spark-md5"
|
||||
|
||||
const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
|
||||
|
||||
export class FileChecksum {
|
||||
constructor(file) {
|
||||
this.file = file
|
||||
this.chunkSize = 2097152 // 2MB
|
||||
this.chunkCount = Math.ceil(this.file.size / this.chunkSize)
|
||||
this.chunkIndex = 0
|
||||
}
|
||||
|
||||
create(callback) {
|
||||
this.callback = callback
|
||||
this.md5Buffer = new SparkMD5.ArrayBuffer
|
||||
this.fileReader = new FileReader
|
||||
this.fileReader.addEventListener("load", event => this.fileReaderDidLoad(event))
|
||||
this.fileReader.addEventListener("error", event => this.fileReaderDidError(event))
|
||||
this.readNextChunk()
|
||||
}
|
||||
|
||||
fileReaderDidLoad(event) {
|
||||
this.md5Buffer.append(event.target.result)
|
||||
|
||||
if (!this.readNextChunk()) {
|
||||
const binaryDigest = this.md5Buffer.end(true)
|
||||
const base64digest = btoa(binaryDigest)
|
||||
this.callback(null, base64digest)
|
||||
}
|
||||
}
|
||||
|
||||
fileReaderDidError(event) {
|
||||
this.callback(`Error reading ${this.file.name}`)
|
||||
}
|
||||
|
||||
readNextChunk() {
|
||||
if (this.chunkIndex < this.chunkCount) {
|
||||
const start = this.chunkIndex * this.chunkSize
|
||||
const end = Math.min(start + this.chunkSize, this.file.size)
|
||||
const bytes = fileSlice.call(this.file, start, end)
|
||||
this.fileReader.readAsArrayBuffer(bytes)
|
||||
this.chunkIndex++
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
42
app/javascript/activestorage/helpers.js
Normal file
42
app/javascript/activestorage/helpers.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
export function getMetaValue(name) {
|
||||
const element = findElement(document.head, `meta[name="${name}"]`)
|
||||
if (element) {
|
||||
return element.getAttribute("content")
|
||||
}
|
||||
}
|
||||
|
||||
export function findElements(root, selector) {
|
||||
if (typeof root == "string") {
|
||||
selector = root
|
||||
root = document
|
||||
}
|
||||
const elements = root.querySelectorAll(selector)
|
||||
return toArray(elements)
|
||||
}
|
||||
|
||||
export function findElement(root, selector) {
|
||||
if (typeof root == "string") {
|
||||
selector = root
|
||||
root = document
|
||||
}
|
||||
return root.querySelector(selector)
|
||||
}
|
||||
|
||||
export function dispatchEvent(element, type, eventInit = {}) {
|
||||
const { bubbles, cancelable, detail } = eventInit
|
||||
const event = document.createEvent("Event")
|
||||
event.initEvent(type, bubbles || true, cancelable || true)
|
||||
event.detail = detail || {}
|
||||
element.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
export function toArray(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
} else if (Array.from) {
|
||||
return Array.from(value)
|
||||
} else {
|
||||
return [].slice.call(value)
|
||||
}
|
||||
}
|
11
app/javascript/activestorage/index.js
Normal file
11
app/javascript/activestorage/index.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { start } from "./ujs"
|
||||
import { DirectUpload } from "./direct_upload"
|
||||
export { start, DirectUpload }
|
||||
|
||||
function autostart() {
|
||||
if (window.ActiveStorage) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(autostart, 1)
|
74
app/javascript/activestorage/ujs.js
Normal file
74
app/javascript/activestorage/ujs.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { DirectUploadsController } from "./direct_uploads_controller"
|
||||
import { findElement } from "./helpers"
|
||||
|
||||
const processingAttribute = "data-direct-uploads-processing"
|
||||
let started = false
|
||||
|
||||
export function start() {
|
||||
if (!started) {
|
||||
started = true
|
||||
document.addEventListener("submit", didSubmitForm)
|
||||
document.addEventListener("ajax:before", didSubmitRemoteElement)
|
||||
}
|
||||
}
|
||||
|
||||
function didSubmitForm(event) {
|
||||
handleFormSubmissionEvent(event)
|
||||
}
|
||||
|
||||
function didSubmitRemoteElement(event) {
|
||||
if (event.target.tagName == "FORM") {
|
||||
handleFormSubmissionEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormSubmissionEvent(event) {
|
||||
const form = event.target
|
||||
|
||||
if (form.hasAttribute(processingAttribute)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new DirectUploadsController(form)
|
||||
const { inputs } = controller
|
||||
|
||||
if (inputs.length) {
|
||||
event.preventDefault()
|
||||
form.setAttribute(processingAttribute, "")
|
||||
inputs.forEach(disable)
|
||||
controller.start(error => {
|
||||
form.removeAttribute(processingAttribute)
|
||||
if (error) {
|
||||
inputs.forEach(enable)
|
||||
} else {
|
||||
submitForm(form)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function submitForm(form) {
|
||||
let button = findElement(form, "input[type=submit]")
|
||||
if (button) {
|
||||
const { disabled } = button
|
||||
button.disabled = false
|
||||
button.click()
|
||||
button.disabled = disabled
|
||||
} else {
|
||||
button = document.createElement("input")
|
||||
button.type = "submit"
|
||||
button.style = "display:none"
|
||||
form.appendChild(button)
|
||||
button.click()
|
||||
form.removeChild(button)
|
||||
}
|
||||
}
|
||||
|
||||
function disable(input) {
|
||||
input.disabled = true
|
||||
}
|
||||
|
||||
function enable(input) {
|
||||
input.disabled = false
|
||||
}
|
22
package.json
Normal file
22
package.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "activestorage",
|
||||
"version": "0.1.0",
|
||||
"description": "Attach cloud and local files in Rails applications",
|
||||
"main": "app/assets/javascripts/activestorage.js",
|
||||
"repository": "git+https://github.com/rails/activestorage.git",
|
||||
"author": "Javan Makhmali <javan@javan.us>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.25.0",
|
||||
"babel-loader": "^7.1.1",
|
||||
"babel-preset-env": "^1.6.0",
|
||||
"spark-md5": "^3.0.0",
|
||||
"webpack": "^3.4.0"
|
||||
},
|
||||
"files": [
|
||||
"app/assets/javascripts/activestorage.js"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "webpack -p"
|
||||
}
|
||||
}
|
27
webpack.config.js
Normal file
27
webpack.config.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
const webpack = require("webpack")
|
||||
const path = require("path")
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
"activestorage": path.resolve(__dirname, "app/javascript/activestorage/index.js"),
|
||||
},
|
||||
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "app/assets/javascripts"),
|
||||
library: "ActiveStorage",
|
||||
libraryTarget: "umd"
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue