Firebase Storage allows you to store and retrieve user-generated content for your applications. In this episode, we'll start to add support for Firebase Storage to our SVG editor .
Let's get started.
Project
We're starting with
the
dailydrip/elm-svg-editor
project, tagged before this
episode
.
First, we'll allow public access to read and write files to our bucket. We could require auth, but I haven't yet forced auth to use the application so it would be a bit unwieldy. We'll set up the Firebase Storage rules in the console:
// lol this is public please don't do this in general
// We only allow writing files less than 50MB in size
service firebase.storage {
match /b/elm-svg-editor.appspot.com/o {
match /{allPaths=**} {
allow read, write: if request.resource.size < 50 * 1024 * 1024;
}
}
}
Next, we'll update the JavaScript to reference storage. The first step is to
rename our
ref
variable to
dbRef
just so it's clear in the future which
service we're dealing with:
// ...
// We'll move the base path out so we can track different uploads for different
// documents. We still don't yet support managing different documents, but we
// know we want to eventually so we'll make it easier to support that later.
let basePath = 'shapes/2'
// Renaming this to dbRef since we'll also have a storageRef shortly
let dbRef = database.ref(basePath)
app.ports.persistShapes.subscribe((shapes) => {
dbRef.set(shapes)
})
// ...
dbRef.on('value', (snapshot) => {
let val = snapshot.val()
delete val.ignoreme
console.log(val)
app.ports.receiveShapes.send(val)
})
// ...
Then we'll introduce the storage system:
// ...
let storage = firebase.storage()
// ...
Next, we'd like to spec out the JavaScript for our ports to handle sending files
in and notifying Elm when they've completed. I've linked to
a nice article on
uploading files in
Elm
.
Previously, we've used
FileReader
for this, but I didn't want to introduce
Native this time.
// We'll subscribe to a port that tells us the dom node ID that we should look
// at for the file.
app.ports.storeFile.subscribe((id) => {
// Get the element by id
let node = document.getElementById(id)
if (node === null) {
return
}
// Get the file and make a new FileReader
// If your file upload field allows multiple files, you might
// want to consider turning this into a `for` loop.
let file = node.files[0]
let reader = new FileReader()
// FileReader API is event based. Once a file is selected
// it fires events. We hook into the `onload` event for our reader.
reader.onload = (event) => {
// The event carries the `target`. The `target` is the file
// that was selected. The result is base64 encoded contents of the file.
let base64encoded = event.target.result;
// We build up an object that has our file contents and file name.
let fileData = {
contents: base64encoded,
filename: file.name
}
// We build the path that we'll use to store the file
let path = basePath + '/' + fileData.filename
// and we generate a reference to that path on the storage system
let storageRef = storage.ref(path)
// We create a new uploadTask for putting our data into Firebase. We'll use
// `putString` for this because we get a data url version of the file from
// our event's target.
let uploadTask =
storageRef
.putString(fileData.contents, firebase.storage.StringFormat.DATA_URL)
// Then when the state changes for our uploadTask, we'll handle it.
// This function takes three callbacks:
//
// - next, for when new things happen
// - error, for when there are errors
// - complete, for when the file upload has completed
uploadTask.on(firebase.storage.TaskEvent.STATE_CHANGED,
// In-progress callback
// When the 'next' event fires, we'll determine the progress of the
// upload, and send a file storage update record into our Elm app.
(snapshot) => {
let progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
// We want to know if it's paused or running
switch (snapshot.state) {
case firebase.storage.TaskState.PAUSED:
app.ports.receiveFileStorageUpdate.send({paused: progress})
break
case firebase.storage.TaskState.RUNNING:
app.ports.receiveFileStorageUpdate.send({running: progress})
break
}
},
// Error callback
// If there's an error, we need to tell the app about it
(error) => {
app.ports.receiveFileStorageUpdate.send({error: error.message})
},
// Success callback
// When we succeed, we'll send a 'complete' update and tell the Elm app
// where the uploaded file can be accessed publicly.
() => {
app.ports.receiveFileStorageUpdate.send({complete: uploadTask.snapshot.downloadURL})
}
)
}
// Finally, we'll tell the reader to read the file
// Connect our FileReader with the file that was selected in our `input` node.
reader.readAsDataURL(file);
})
This is everything necessary on the JavaScript side. Let's look at the Elm Ports to support this:
port module Ports
exposing
( -- ...
, storeFile
, receiveFileStorageUpdate
)
-- ...
-- INBOUND PORTS
-- ...
port receiveFileStorageUpdate : (Value -> msg) -> Sub msg
-- ...
-- OUTBOUND PORTS
-- ...
port storeFile : String -> Cmd msg
We'll want to add a few
Msg
types to handle this.
module Msg
-- ...
type Msg
= -- ...
| BeginImageUpload SvgPosition
| CancelImageUpload
| StoreFile String
| ReceiveFileStorageUpdate Value
Here we're adding messages to tell our app that we'd like to begin uploading an image that will ultimately show up at a particular position, we'd like to cancel the upload, we'd like to tell our JavaScript to store the file, and we'd like to receive updates about how that's going.
We'll wire up the
receiveFileStorageUpdate
port so we can receive those
updates:
module App exposing (..)
-- ...
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ -- ...
, Ports.receiveFileStorageUpdate ReceiveFileStorageUpdate
]
We need to add support for uploaded files in our Model:
module Model
exposing
( -- ...
, ImageUpload(..)
, Upload(..)
)
-- ...
-- We're going to have two cases for an ImageUpload - it's either waiting for a
-- file to be selected (and knows where to put it on the canvas), or it's
-- waiting for the Upload to complete. We'll introduce an `Upload` type to
-- define the states our upload can have.
type ImageUpload
= AwaitingFileSelection SvgPosition
| AwaitingCompletion SvgPosition Upload
-- An upload can be running or paused, in which case we want to know how far
-- along it is. It can have errored, in which case we'd like to know the error
-- message. Or it can be completed, in which case we'd like to know the path to
-- the file on Firebase.
type Upload
= Running Float
| Paused Float
| Errored String
| Completed String
-- We'll add a `Maybe ImageUpload` to our model, because we could be in the case
-- where we're uploading an image.
type alias Model =
{ -- ...
, imageUpload : Maybe ImageUpload
}
-- ...
-- And we'll have no image upload in our model initially
initialModel : Model
initialModel =
{ -- ...
, imageUpload = Nothing
}
With this, we can in theory support uploading an image. Next, we'll wire up the
Update
to handle our new message types:
module Update exposing (update)
import Model
exposing
( -- ...
, ImageUpload(..)
, Upload(..)
)
-- ...
-- We'll have to decode the upload progress coming in from the JavaScript
import Decoder exposing (shapesDecoder, userDecoder, uploadDecoder)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ mouse } as model) =
case msg of
-- ...
-- We need to handle a message that signals we'll start the image upload
-- UI process
BeginImageUpload svgPosition ->
{ model | imageUpload = Just (AwaitingFileSelection svgPosition) }
! []
-- We have to support canceling the process
CancelImageUpload ->
{ model | imageUpload = Nothing } ! []
-- When we ask to store a file, if we're in the right state, we'll
-- transition to the `AwaitingCompletion` state, say we're Running with
-- 0% complete, and tell the JavaScript to store the file at our DOM
-- node
StoreFile id ->
case model.imageUpload of
Just (AwaitingFileSelection svgPosition) ->
{ model | imageUpload = Just (AwaitingCompletion svgPosition (Running 0)) } ! [ Ports.storeFile id ]
_ ->
model ! []
-- When we get updates on the status, we'll handle the inbound
-- information only if we're waiting to hear about it. We'll also
-- pattern match out the svg position so we can ultimately place the
-- uploaded image in the correct position when it completes uploading
ReceiveFileStorageUpdate value ->
case model.imageUpload of
Just (AwaitingCompletion svgPosition _) ->
handleImageUpload model svgPosition value ! []
_ ->
model ! []
-- Handling the upload consists of decoding the information we're passed and
-- updating the `imageUpload` field in the model
handleImageUpload : Model -> SvgPosition -> Decode.Value -> Model
handleImageUpload model svgPosition value =
let
-- We'll use the `uploadDecoder` to decode the value
uploadResult =
Decode.decodeValue uploadDecoder (Debug.log "val" value)
in
case uploadResult of
-- if it successfully decodes...
Ok upload ->
-- We'll look at its state
case upload of
-- If it's a complete upload, we'll just notify ourselves in
-- the console for now and cancel the imageUpload
Completed fileUrl ->
let
_ =
Debug.log "image is available at" fileUrl
in
{ model | imageUpload = Nothing }
-- Otherwise, we'll update the imageUpload with the upload
-- state
u ->
{ model | imageUpload = Just (AwaitingCompletion svgPosition u) }
-- And if there was an error, we'll print it to the console for now
Err error ->
let
_ =
Debug.log "error decoding upload" error
in
model
All that remains is to add our
uploadDecoder
and wire up the UI. Let's look at
the
uploadDecoder
:
module Decoder exposing (shapesDecoder, userDecoder, uploadDecoder)
-- ...
import Model
exposing
( -- ...
, Upload(..)
)
-- ...
uploadDecoder : Decoder Upload
uploadDecoder =
-- We'll use `oneOf`, which will try different decoders until it finds a
-- successful decoder.
oneOf <|
-- Then we'll look at each possible shape and decode them appropriately
[ field "running" <| map Running float
, field "error" <| map Errored string
, field "paused" <| map Paused float
, field "complete" <| map Completed string
]
With that, we could in theory find out about image uploads. However, there's no UI to do this yet. Tomorrow, we'll complete the loop and wire up the UI to actually add an Image.
Summary
Today we looked at how to integrate Firebase Storage with our SVG Editor for adding images to our SVG files. We haven't wired it up to the user interface yet, but we've handled all of the possible cases that we'll get and we should be able to show upload progress. In the next drip, we'll wire up the UI and add Image shapes to the app so we can render our uploaded images. See you soon!
Resources
-
dailydrip/elm-svg-editortagged before this episode -
dailydrip/elm-svg-editortagged after this episode - Firebase Storage
-
Storing files in Elm via ports
- We've done this before with FileReader, but this time I wanted to look at doing it without needing to use Native.
Comments for Firebase Storage