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