elm-spa/examples/from-scratch
2020-04-07 22:10:31 -05:00
..
README.md begin work on from scratch guide 2020-04-07 22:10:31 -05:00

from scratch

a guide on how to use elm-spa without the cli

getting setup

  1. Install elm

  2. Optional: Install VS Code and the elm extension

creating a new project

Create a new Elm project using the elm init command:

elm init

This will create an elm.json file and a src folder. Let's install two more packages for our project:

elm install elm/url
elm install ryannhg/elm-spa

src/Main.elm

First, we create a new file called src/Main.elm- which is the entrypoint for our new Elm application. Let's build it together:

imports and main

We need to create a main function for Elm to call when our application starts up. Browser.application supports client-side routing, so that's the function we'll want to call for our single page application.

module Main exposing (main)

import Browser
import Browser.Navigation as Nav
import Document exposing (Document)
import Global
import Pages
import Url exposing (Url)


main : Program Global.Flags Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        , onUrlRequest = LinkChanged
        , onUrlChange = UrlChanged
        }

The Document, Global, and Pages modules haven't been created yet, but Browser, Browser.Navigation, and Url come from the elm/browser and elm/url packages.

model and init

Here we'll store four things:

  1. The current URL

  2. A unique key used for navigation

  3. The global state of our app

  4. The state of the page we are currently viewing

type alias Model =
    { url : Url
    , key : Nav.Key
    , global : Global.Model
    , page : Pages.Model
    }

We'll implement Global.Model and Pages.Model soon, but for now let's continue by implementing the init function.

init : Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
    let
        ( globalModel, globalCmd ) =
            Global.init flags url key

        ( pageModel, pageCmd, pageGlobalCmd ) =
            Pages.init url globalModel
    in
    ( Model url key globalModel pagesModel
    , Cmd.batch
        [ Cmd.map FromGlobal globalCmd
        , Cmd.map FromGlobal pageGlobalCmd
        , Cmd.map FromPage pageCmd
        ]
    )

Here we assume Global.init handles the initialization of the global state between pages, and can send a global command like an HTTP request or another side effect.

Similarly, Pages.init initializes the current page based on the current URL and the initialize global state. In addition to returning Cmd Pages.Msg, pages can also return Cmd Global.Msg. This allows them to affect the global state shared between pages.

We'll implement those functions together later!

msg and update

type Msg
    = LinkClicked Browser.UrlRequest
    | UrlChanged Url
    | FromGlobal Global.Msg
    | FromPage Page.Msg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        LinkClicked (Browser.Internal url) ->
            ( model
            , Nav.pushUrl model.key (Url.toString url)
            )

        LinkClicked (Browser.External href) ->
            ( model
            , Nav.load href
            )

        UrlChanged url ->
            let
                ( pageModel, pageCmd, pageGlobalCmd ) =
                    Pages.init url model.global
            in
            ( { model | url = url, page = pageModel }
            , Cmd.batch
                [ Cmd.map FromGlobal pageGlobalCmd
                , Cmd.map FromPage pageCmd
                ]
            )

        FromGlobal globalMsg ->
            let
                ( globalModel, globalCmd ) =
                    Global.update globalMsg model.global
            in
            ( { model | global = globalModel }
            , Cmd.map FromGlobal globalCmd
            )

        FromPages pageMsg ->
            let
                ( pageModel, pageCmd, pageGlobalCmd ) =
                    Pages.update pageMsg model.page model.global
            in
            ( { model | page = pageModel }
            , Cmd.batch
                [ Cmd.map FromGlobal pageGlobalCmd
                , Cmd.map FromPage pageCmd
                ]
            )

Here we define and handle the four messages we might receive from the user:

  1. They click a link, either internal or external, and we navigate to the correct page.

  2. The URL changes and we need to initialize the new page.

  3. We received an update for the global state between pages.

  4. We received an update for a page.

view + subscriptions

The view and subscriptions for the page are based on the current Model of the application:

view : Model -> Document Msg
view model =
    Global.view
        { global = model.global
        , fromGlobal = FromGlobal
        , page = Document.map FromPage (Pages.view model.page model.global)
        }

The view function calls the global layout, and provides three things:

  1. The current global state of the application.
  2. A way to upgrade a Global.Msg into a Msg.
  3. The page's view, so it can decide where to render the page.

why those three values?

The pages within our app will return Document Page.Msg.

But we want components in our global view to be able to send global messages (Global.Msg).

In Elm, our function cannot return both Document Page.Msg and Document Global.Msg. To make sure our view function returns the same type:

  1. We call Document.map FromPage to convert Document Pages.Msg into Document Msg
  2. We provide fromGlobal to Global.view, so it can convert Global.Msg values into Msg values!

Now we are returning Document Msg to Main.view, and we can handle messages from different sources. Later, we'll see Global.view- which will have a more concrete example for how to use those three values.

For now, let's wrap up by handling subscriptions!

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ Sub.map FromGlobal (Global.subscriptions model.global)
        , Sub.map FromPage (Pages.subscriptions model.page model.global)
        ]

Finally, we subscribe to events from the global and page modules, in case we want to track things like window resize or keyboard events.

That's it! The source code for src/Main.elm file is available here.

src/Document.elm

Before we get into Global and Pages, let's define the Document module, which defines what our pages should be returning:

module Document exposing (Document, map)

import Html exposing (Html)


type alias Document msg =
    { title : String
    , body : List (Html msg)
    }


map : (msg1 -> msg2) -> Document msg1 -> Document msg2
map fn doc =
    { title = doc.title
    , body = List.map (Html.map fn) doc.body
    }

That's all we need for this file! We just redefined Browser.Document and created a map function a way to go from one Document to another. The map function is the one we used above in src/Main.elm.

src/Global.elm

The Global module initializes, updates, views, and handles subscriptions for the state we want to share across pages.

For example, if a user logs in, we don't want navigating from page to page to log them back out!

For that reason, it's nice to have a Global.Model for our application.

module Global exposing
    ( Flags
    , Model
    , Msg
    , init
    , update
    , view
    , subscriptions
    -- commands
    , increment
    , navigateTo
    )

import Browser.Navigation as Nav
import Document exposing (Document)
import Html exposing (Html)
import Html.Attributes exposing (class)
import Url exposing (Url)
import Route exposing (Route)
import Task

flags, model, and init

type alias Flags =
    ()


type alias Model =
    { key : Nav.Key
    , counter : Int
    }


init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ _ key =
    ( Model key 0
    , Cmd.none
    )

The Flags type is the data we pass in from JavaScript when starting our Elm application. Here we are using an empty tuple () to say that we won't be taking in any data from JS.

The Global.Model needs to store Nav.Key so it can navigate between pages. For that reason, we ignore the flags and url, but store key to our model.

For this example application, we're also storing a global counter value. Later, we'll demonstrate how to change that global counter from both pages and global components!

msg and update

type Msg
    = NavigatedTo Route
    | Increment


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NavigatedTo route ->
            ( model
            , Nav.pushUrl model.key (Route.toHref route)
            )
        
        Increment ->
            ( { model | counter = model.counter + 1 }
            , Cmd.none
            )

Here we handle NavigatedTo, a message that comes with a Route. A Route is just a custom type that holds all the URLs our application accepts. We'll define the Route module after this one!

When we get the NavigatedTo message from our application, we use the Route.toHref function to convert our route to a String. Then we can call Nav.pushUrl to change the URL for our app.

We also include an implementation for Increment. We'll see an example of changing global state soon.

global commands

To allow pages to send Cmd Global.Msg, we need to expose the commands we want our users to call.

Here we'll define a helper function called send that turns a Global.Msg into a Cmd Global.Msg:

send : Msg -> Cmd Msg
send msg =
    Task.succeed msg |> Task.perform identity

From there, we can expose commands easily:

navigateTo : Route -> Cmd Msg
navigateTo route =
    send (NavigatedTo route)
increment : Cmd Msg
increment =
    send Increment

view + subscriptions

Our view function received the three pieces of information we passed in from Main.view.

view :
    { global : Model
    , toMsg : Msg -> msg
    , page : Document msg
    }
    -> Document msg
view { global, toMsg, page } =
    { title = page.title
    , body =
        [ Html.div [ class "layout" ]
            [ navbar
                { increment = toMsg Increment
                , counter = global.counter
                }
            , Html.div [ class "page" ] page.body
            , footer
            ]
        ]
    }


navbar :
    { increment : msg
    , counter : Int
    }
    -> Html msg
navbar options =
    Html.header [ class "navbar" ]
        [ Html.text (String.fromInt options.counter)
        , Html.button
            [ Events.onClick options.increment ]
            [ Html.text "+" ]
        ]


footer : Html msg
footer =
    Html.footer [ class "footer" ] [ text "built with elm!" ]

The view function has access to the current global model, page, and shared components can even send messages with the help of toMsg.

The page document's title is used by our view.

By passing in the page value, our layout can decide where to render the page view. Here we render it in between two components: navbar and footer

Our navbar is able to access the current counter value and can send the increment message when the button is clicked.

Here we use toMsg to make sure that the return type is Html msg instead of Html Msg. Using toMsg allows us to put the navbar alongside the page.body and footer- because they all return Html msg!

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.none

Our application doesn't require any subscriptions, but you could add them in here if you need them later!

That's src/Global.elm. The complete source code is available here.

src/Route.elm

Earlier in our Global module, we used Route to handle page navigation messages. Let's define a Route module for this application, so we can see how it works.

module Route exposing (Route(..), fromUrl, toHref)

import Url exposing (Url)
import Url.Parser exposing (Parser, (</>), string, s)

Our route module will be using a Url.Parser from the elm/url package to implement fromUrl.

Route

type Route
    = Top
    | AboutUs
    | Posts
    | Post String
    | NotFound

When we define this Route type, we're saying that these are the only five pages a user can ever be on while using our application.

fromUrl

We can create our Route by parsing a Url.

fromUrl : Url -> Route
fromUrl url =
    [ Parser.map Top Parser.top
    , Parser.map AboutUs (Parser.s "about-us")
    , Parser.map Posts (Parser.s "posts")
    , Parser.map Post (Parser.s "posts" </> Parser.string)
    ]
        |> Parser.oneOf
        |> Parser.run url
        |> Maybe.withDefault NotFound

Here we try to find a route match. If we can't find one, we default to NotFound. That way we always have a Route no matter what Url we are given.

toHref

If we want to turn our AboutUs route back into /about-us, we'll need to define a function to do that for us:

toHref : Route -> String
toHref route =
    case route of
        Top ->
            "/"

        AboutUs ->
            "/about-us"
        
        Posts ->
            "/posts"
        
        Post id ->
            "/posts/" ++ id
        
        NotFound ->
            "/not-found"

This function allows all our URLs to be handled in one place in the application.