Spotted Arrow

2017-04-29

Let's Build a Local Weather App in Elm

Elm has many great features, from the Elm Architecture to type aliases to compiler error messages. Another great feature is Elm’s interoperability with JavaScript through ports and subscriptions. Ports allow information to be passed between my Elm application and the JavaScript on the front-end. Subscriptions allow me to consume data coming through ports from the front-end so that I can pass it to my update function and make changes to my model.

In this lesson, I will build a local weather app that is probably one of the ugliest apps you will ever see. You definitely won’t ever see this app on Awwwards. However, this lesson is about learning Elm, so I hope you don’t mind because I sure don’t!

Unfortunately, in this lesson I won’t be using Ellie, the friendly Elm editor. I need to be able to use a couple Github repositories and getting down and dirty with Elm in VSCode seems like the most logical solution. There really isn’t a whole lot to do to get started using Elm in VSCode. First, you should download Elm and get it on your local machine/computer.

I highly recommend using elm-format with VSCode, it will make your code all nice and purty and format it according to Elm conventions. I think that is all you will really need for this lesson. Enough with the formalities. Let’s start coding.

To start I will first dive into the folder structure of the app. The final outcome will look a little something like this:

dist/ index.html main.js elm-stuff/ node_modules/ scripts/ elm-ops-tooling/ src/ Main.elm Ports.elm Types.elm vendor/ elm-jsonp .gitignore elm-package.json package.json

The compiled output of the src/Main.elm will be to dist/main.js. One of the Github repos I am using is elm-jsonp which you can see is under vendor. I need to run a python script to port over elm-jsonp into my elm-stuff so I am using another Github repo, elm-ops-tooling, to do the grunt work in that area. Nothing too busy going on here, just wanted to provide an overview of the project before I got into the meat and potatoes.

To begin I will get into the index.html file. Here I will write the scripts that send information to the Elm app. In the body of the index.html, I am adding a div with a class of main. Then I am adding a script tag whose src is my main.js script, which doesn’t exist now but will exist soon.

Then I add another script tag that does a couple things. First, it instantiates my Elm app and embeds it to the DOM. Second, it calls a function that asks the user if they want to share their location. If the user agrees to share their location, the user’s geolocation information will be sent through a port to the Elm app. If they do not, I will send an error to another Elm port.

In this lesson, I won’t be showing any error handling but I will provide the mechanisms to do so. As a good software developer, you should implement views for all user experiences but I will just be demonstrating the “happy path,” a.k.a. when things go right. The outcome of the index.html file should now look like this:

<body>
  <div id="main"></div>
  <script src="main.js"></script>
  <script>
    var node = document.getElementById("main");
    var app = Elm.Main.embed(node);

    if (window.navigator.geolocation) {
      window.navigator.geolocation.getCurrentPosition(success, failure);
    }

    function success(position) {
      const coords = position.coords;
      const latitude = coords.latitude;
      const longitude = coords.longitude;
      app.ports.position.send({ latitude, longitude });
    }

    function failure(error) {
      const code = error.code;
      const message = error.message;
      app.ports.error.send({ code, message });
    }
  </script>
</body>

This won’t actually work if I try to open index.html in a browser because I don’t have a main.js file in the directory. This makes Elm in the script tag undefined. To fix this I will move on to my Elm files and work on rendering my coordinates on the screen.

In my src directory, I will create the files I need: Main.elm, Ports.elm, and Types.elm. In my Types.elm file, I will add two types: Position and Error. The Position type will be quite simple, it will have two fields that are both floats: latitude and longitude. The Error type will also have two fields, status that will be an integer and the message that will be a string. My Types.elm file will look like this:

module Types exposing (..)

type alias Position =
   { latitude : Float
   , longitude : Float
   }

type alias Error =
   { status : Int
   , message : String
   }

Modeling my types is great because it ensures the data I pass around my application is in the shape I expect. If my view expects my model to have a position of latitude and longitude and instead I put lat and lng, I will get a compile error telling me to update my view because it was expecting a value other than lat and lng. Better to catch these pesky bugs before the program is compiled then to see them at run-time!

In my Ports.elm file, I will add the ports that I need to get my position or the error if the server fails to return back my coordinates. I will pull in the types I need so that the port correctly parses the JavaScript data before it comes part of my model.

port module Ports exposing (..)

import Types exposing (Position, Error)

port position : (Position -> msg) -> Sub msg

port error : (Error -> msg) -> Sub msg

When the JavaScript data comes through the port Elm makes sure it adheres to the type, either Position or Error, and then sends out a Sub msg that the update function listens for. When the update function gets my Sub msg I can then update my model accordingly and render a new view with my local coordinates.

To render my local coordinates I need to wire up my Main.elm file. I will first start with my model since that is what my view is based on. My model is pretty simple at the moment, it will have position and error fields based on you guessed it, Position and Error types.

type alias Model =
    { position : Position
    , error : Error
    }

I will be using Elm’s Html.program which means I need an init function. Again this will be pretty basic. It will have placeholder values for both the position and error fields when the app initializes.

init : ( Model, Cmd Msg )
init =
 ( Model (Position 0.0 0.0) (Error 0 ""), Cmd.none )

Lets move on to our messages that send updates to our update function. I have two messages that are important at the moment and both come from our subscriptions:

type Msg
    = NewPositionMsg Position
    | NewErrorMsg Error

These messages are what the subscription sends to our update function. If we get the coordinates then a NewPositionMsg with the actual Position coordinates are sent to update. From there I can update my model. The subscription can also send a NewErrorMsg with the Error in case it errors on me. With these messages in mind, I can now update my subscriptions function.

My subscriptions take in values and send out a Sub Msg based on those values. I am listening for both a position and an error so I will need to use Sub.batch to handle multiple subscriptions.

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ position NewPositionMsg
        , error NewErrorMsg
        ]

The update function will listen for these subscriptions and make updates to the model accordingly. Here is how I am handling those subscription messages in my update function:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NewPositionMsg position ->
            ( { model | position = position }, Cmd.none)
        NewErrorMsg error ->
            ( { model | error = error}, Cmd.none )

Pretty simple, right? I am updating my model based on the subscription message. If it’s a NewPositionMsg with a position, I update my position field on my model with the new position. If it’s a NewErrorMsg, I update my error field in my model with the new error. Nothing too crazy going on here. Now I will define my view function.

view : Model -> Html a
view model =
    div []
        [ div [] [ text (toString model.position.latitude)  ]
        , div [] [ text (toString model.position.longitude) ]
        ]

Again, nothing crazy going on here. Just rendering my view based on the model. As you can tell I am not rendering any view for my error. I leave that up to you 😉!

Now time to wire this up!

main : Program Never Model Msg
main =
    Html.program
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        }

I am at the midway point and I have been driving a little blind because I haven’t checked to see if this thing actually works. Let’s see this thing in action!

To compile my code more effectively I am using a couple npm packages: elm and chokidar-cli. These packages allow me to compile my Elm files on every save. In my package.json I have three npm commands.

"start": "npm run watch:elm",
"watch:elm": "chokidar '**/*.elm' -c 'npm run build:elm' --initial",
"build:elm": "elm make src/Main.elm --output dist/main.js"

This idea for compiling using npm came from Maximillian Hoffman in this blog post.

Now if you run npm init -y to create an package.json file and then add the scripts above to scripts in the package.json and then run npm i -D elm chokidar-cli and then run npm start at the root of your directory, it will almost work 😃.

The reason it doesn’t is because the Elm compiler cannot find our Elm files, which are in src/. At this point though an elm-package.json has been created. We need to update source-directories in the elm-package.json from being . to ./src. If you run cmd + c and then run npm start again you should successfully compile your Elm files into main.js and see it sitting comfortably in your dist folder.

Now if you open dist/index.html in a browser you should be prompted to share your location, and not too long afterward you should see your coordinates on the screen.

Now you can take a breather and grab a bite to eat or some fresh air, you earned it!

Here is the code up to this point: code.

This is the part of the post that you will start to hate me. Things will get a tad complicated but hopefully not so bad that you start asking rhetorically, “really?!😡”

To set the foundation for this second part I need to pull in a couple projects from Github and create an account with darksky.net, the API I will use to get the local temperature based on my coordinates.

If you haven’t already, head on over to darksky.net and create a developer account by clicking on the Developers tab at the top of the screen. Once you sign up, you will be redirected to your console where you will find your API key and a sample request. A sample request to the API will look like this:

https://api.darksky.net/forecast/YOUR_API_KEY/37.8267,-122.4233

Darksky enforces strict CORS so I won’t be able to make this request from my browser. That is why I need to use elm-jsonp to make the request instead. I also will need to run a script to unpack elm-jsonp into my elm-stuff directory. For that, I will use elm-ops-tooling. I will now create two directories, scripts and vendor. Scripts will house elm-ops-tooling and vendor will house elm-jsonp. Head on over to Github and clone those repositories into their respective directories.

Once I have the repositories in their respective directories I will run a simple script, elm_self_publish, to unpack elm-jsonp and and add it to my elm-stuff directory. The command will look like this:

python scripts/elm-ops-tooling/elm_self_publish.py vendor/elm-jsonp ./

Now that elm-jsonp is self-published I can import the module into my Main.elm. However, for it to work correctly we also need the elm-lang/http library because elm-jsonp depends on it. To install elm-lang/http just run elm package install elm-lang/http in your terminal.

I want to begin adding functionality to my Main.elm so I will again start with my model. I will need to add a new field to my model for the temperature. My model will now look like this:

type alias Model =
    { position : Position
    , error : Error
    , temperature : Float
    }

Next, I will add a placeholder value to my init function. Otherwise, I will get an error saying that my init function does not adhere to the shape of the model.

init : ( Model, Cmd Msg )
init =
    ( Model (Position 0.0 0.0) (Error 0 "") 0.0, Cmd.none )

After that, I need to add a new message for my update function to listen for the new temperature I will get after hitting the Darksky API with my request. My Msg types will now look like so:

type Msg
    = NewPositionMsg Position
    | NewErrorMsg Error
    | NewTemperature (Result Http.Error Float)

I now need to create a helper function that will take my latitude and longitude and send a request to the Darksky API based on those coordinates. I will call this function getTemperature.

getTemperature : Position -> Task Http.Error Float
getTemperature position =
    let
        ( lat, lng ) =
        ( toString position.latitude, toString position.longitude )
    in
        Jsonp.get decodeTemperature ("https://api.darksky.net/forecast/YOUR_API_KEY/" ++ lat ++ "," ++ lng)

This function will take a Position and return send out a Task that returns either an Http.Error or the result, which will be a Float. I am also using a a let expression here to destructure two variables, lat and lng, which turn the position.latitude and position.longitude, which are floats, into strings.

If you notice there is another function called decodeTemperature. Elm does not automagically decode JSON into an Elm format. That is something I will have to do manually using a decoder. This makes sure that the information I get back from the API, which is JSON, can be added to my model, which is in Elm. I just need temperature, which is nested inside of another field called currently, so my decoder will look like this:

decodeTemperature : Decode.Decoder Float
decodeTemperature =
    Decode.at [ "currently", "temperature" ] Decode.float

Next I need to change my update function to handle when the temperature comes in. Because I am sending a Task, that means I will receive two results. Therefore, I need two handlers, one for the successful response and one for the error. One other thing I need to do is update my NewPositionMsg action. Previously I was sending just a Cmd.none but what I want to send is an actual Task to the Darksky API. Therefore I will use Task.attempt that takes a Msg, in this case, NewTemperature, and sends out a request that is shaped by getTemperature. When the NewTemperature comes in I update my model with the temperature from the API.

My update function now looks like this

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NewPositionMsg position ->
        ( { model | position = position }, Task.attempt NewTemperature (getTemperature position) )

        NewErrorMsg error ->
        ( { model | error = error }, Cmd.none )

        NewTemperature (Ok temperature) ->
        ( { model | temperature = temperature }, Cmd.none )

        NewTemperature (Err _) ->
        model ! []

My view will be updated as well. Nothing too crazy, just adding a div that shows the temperature.

view : Model -> Html a
view model =
    div []
        [ div [] [ text (toString model.position.latitude) ]
        , div [] [ text (toString model.position.longitude) ]
        , div [] [ text (toString model.temperature) ]
        ]

If you did not realize already, you will need to pull in the new libraries at the top of the Main.elm file. They include:

import Http
import Jsonp
import Task exposing (Task)
import Json.Decode as Decode

Okay, if you run npm start your program should compile and if you open dist/index.html in a browser you should be prompted to share your location and if you do you should see your latitude, longitude, and temperature on the screen.

Note: You may receive errors that say you have corrupted files. You may want to delete your elm-stuff folder and recompile your code.

You have reached the finish line 🏁, congratulations! Thanks for hanging in there. Here is the completed version of the app: code.

I hope this lesson was helpful in describing how to work with ports and to either render data or kick off other actions based on that data as well as other Elm concepts.

This article has Webmentions