single page apps made easy
Go to file
Ryan Haskell-Glatz 0f2d1f8cf8 now im not a liar
2019-10-08 00:33:13 -05:00
examples now im not a liar 2019-10-08 00:33:13 -05:00
src now im not a liar 2019-10-08 00:33:13 -05:00
.gitignore add in example 2019-10-04 17:16:02 -05:00
elm.json refactoring and documentation 2019-10-08 00:22:17 -05:00
netlify.toml what do 2019-10-04 20:37:04 -05:00
README.md better tho 2019-10-08 00:30:58 -05:00

ryannhg/elm-app

a way to build single page apps with Elm.

installing

elm install ryannhg/elm-app

motivation

Every time I try and create a single page application from scratch in Elm, I end up repeating a few first steps from scratch:

  • Routing - Implementing onUrlChange and onUrlRequest for my update function.

  • Page Transitions - Fading in the whole app on load and fading in/out the pages (not the persistent stuff) on route change.

  • Wiring up pages - Every page has it's own Model, Msg, init, update, view, and subscriptions. I need to bring those together at the top level.

  • Sharing app model - In addition to updating the page model, I need a way for pages to update the shared app model used across pages (signed in users).

This package is an attempt to create a few abstractions on top of Browser.application to make creating single page applications focus on what makes your app unique.

is it real?


examples are helpful!

Let's walk through the package together, at a high-level, with some code!

src/Main.elm

our-project/
  elm.json
  src/
    Main.elm ✨

This is the entrypoint to the application, and connects all the parts of our Application together:

module Main exposing (main)

import Application

main =
  Application.create
    { routing = -- TODO
    , layout = -- TODO
    , pages = -- TODO
    }

As you can see, Application.create is a function that takes in a record with three properties:

  1. routing - handles URLs and page transitions

  2. layout - the app-level init, update, view, etc.

  3. pages - the page-level init, update, view, etc.

routing

module Main exposing (main)

import Application
import Route 

main =
  Application.create
    { routing =
        { fromUrl = Route.fromUrl 
        , toPath = Route.toPath 
        , transitionSpeed = 200  
        }
    , layout = -- TODO
    , pages = -- TODO
    }

The record for routing only has three properties:

  1. fromUrl - a function that turns a Url into a Route

  2. toPath - a function that turns a Route into a String used for links.

  3. transitionSpeed - number of milliseconds it takes to fade in/out pages.

The implementation for fromUrl and toPath don't come from the src/Main.elm. Instead we create a new file called src/Route.elm, which handles all this for us in one place!

We'll link to that in a bit!

layout

module Main exposing (main)

import Application
import Route
import Components.Layout as Layout 

main =
  Application.create
    { routing =
        { fromUrl = Route.fromUrl
        , toPath = Route.toPath
        , transitionSpeed = 200 
        }
    , layout =
        { init = Layout.init 
        , update = Layout.update 
        , view = Layout.view 
        , subscriptions = Layout.subscriptions 
        }
    , pages = -- TODO
    }

The layout property introduces four new pieces:

  1. init - how to initialize the shared state.

  2. update - how to update the app-level state (and routing commands).

  3. view - the app-level view (and where to render our page view)

  4. subscriptions - app-level subscriptions (regardless of which page we're on)

Just like before, a new file src/Components/Layout.elm will contain all the functions we'll need for the layout, so that Main.elm is relatively focused.

pages

module Main exposing (main)

import Application
import Route
import Components.Layout as Layout
import Pages 

main =
  Application.create
    { routing =
        { fromUrl = Route.fromUrl
        , toPath = Route.toPath
        , transitionSpeed = 200 
        }
    , layout =
        { init = Layout.init
        , update = Layout.update
        , view = Layout.view
        , subscriptions = Layout.subscriptions
        }
    , pages =
        { init = Pages.init 
        , update = Pages.update 
        , bundle = Pages.bundle 
        }
    }

Much like the last property, pages is just a few functions.

The init and update parts are fairly the same, but there's a new property that might look strange: bundle.

The "bundle" is a combination of view, title, subscriptions that allows our new src/Pages.elm file to reduce a bit of boilerplate! (There's a better explanation in the src/Pages.elm section of the guide.)

that's it for Main.elm!

As the final touch, we can update our import statements to add in a type annotation for the main function:

module Main exposing (main)

import Application exposing (Application) 
import Flags exposing (Flags) 
import Global 
import Route exposing (Route) 
import Components.Layout as Layout
import Pages

main : Application Flags Route Global.Model Global.Msg Pages.Model Pages.Msg 
main =
  Application.create
    { routing =
        { fromUrl = Route.fromUrl
        , toPath = Route.toPath
        , transitionSpeed = 200 
        }
    , layout =
        { init = Layout.init
        , update = Layout.update
        , view = Layout.view
        , subscriptions = Layout.subscriptions
        }
    , pages =
        { init = Pages.init
        , update = Pages.update
        , bundle = Pages.bundle
        }
    }

Instead of main being the traditional Program Flags Model Msg type, here we use Application Flags Route Global.Model Global.Msg Pages.Model Pages.Msg, which is very long and spooky!

This is caused by the fact that our Application.create needs to know more about the Flags, Route, Global, and Pages types so it can do work for us.

But enough of that let's move on to routing next!


src/Route.elm

our-project/
  elm.json
  src/
    Main.elm
    Route.elm ✨

in our new file, we need to create a custom type to handle all the possible routes.

module Route exposing (Route(..))

type Route
  = Homepage
  | SignIn

For now, there is only two routes: Homepage and SignIn.

We also need to make fromUrl and toPath so our application handles routing and page navigation correctly!

For that, we need to install the official elm/url package:

elm install elm/url

And use the newly installed Url and Url.Parser modules like this:

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

import Url exposing (Url) 
import Url.Parser as Parser exposing (Parser) 

type Route
  = Homepage
  | SignIn

fromUrl : Url -> Route 
-- TODO

toPath : Route -> String 
-- TODO

fromUrl

Let's get started on implementing fromUrl by using the Parser module:

type Route
  = Homepage
  | SignIn
  | NotFound  -- see note #2

fromUrl : Url -> Route
fromUrl url =
  let
    router =
      Parser.oneOf  -- see note #1
        [ Parser.map Homepage Parser.top
        , Parser.map SignIn (Parser.s "sign-in")
        ]
  in
    Parser.parse router url
    |> Maybe.withDefault NotFound  -- see note #2

Notes

  1. With Parser.oneOf, we match / to Homepage and /sign-in to SignIn.

  2. Parser.parse returns a Maybe Route because it not find a match in our router. That means we need to add a NotFound case (good catch, Elm!)

toPath

It turns out toPath is really easy, its just a case expression:

toPath : Route -> String 
toPath route =
  case route of
    Homepage -> "/"
    SignIn -> "/sign-in"
    NotFound -> "/not-found"

that's it for Route.elm!

here's the complete file we made.

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

import Url exposing (Url)
import Url.Parser as Parser exposing (Parser)

type Route
  = Homepage
  | SignIn
  | NotFound

fromUrl : Url -> Route
fromUrl url =
  let
    router =
      Parser.oneOf
        [ Parser.map Homepage Parser.top
        , Parser.map SignIn (Parser.s "sign-in")
        ]
  in
    Parser.parse router url
    |> Maybe.withDefault NotFound

toPath : Route -> String
toPath route =
  case route of
    Homepage -> "/"
    SignIn -> "/sign-in"
    NotFound -> "/not-found"

You can learn how to add more routes by looking at:

  1. the elm/url docs - https://package.elm-lang.org/packages/elm/url/latest

  2. the example in this repo -https://github.com/ryannhg/elm-app/blob/master/examples/basic/src/Route.elm


src/Flags.elm

our-project/
  elm.json
  src/
    Main.elm
    Route.elm
    Flags.elm ✨

For this app, we don't actually have flags, so we return an empty tuple.

module Flags exposing (Flags)

type alias Flags = ()

So let's move onto something more interesting!


src/Global.elm

our-project/
  elm.json
  src/
    Main.elm
    Route.elm
    Flags.elm
    Global.elm ✨

The purpose of Global.elm is to define the Model and Msg types we'll share across pages and use in our layout functions:

module Global exposing ( Model, Msg(..) )

type alias Model =
    { isSignedIn : Bool
    }

type Msg
    = SignIn
    | SignOut

Here we create a simple record to keep track of the user's sign in status.

Let's see an example of Global.Model and Global.Msg being used in our layout:

src/Components/Layout.elm

our-project/
  elm.json
  src/
    Main.elm
    Route.elm
    Flags.elm
    Global.elm
    Components/
      Layout.elm ✨

To implement an app-level layout, we'll need a new file:

module Components.Layout exposing (init, update, view, subscriptions)

import Global
import Route exposing (Route)

-- ...

This file needs to export the following four functions:

init

init :
    { navigateTo : Route -> Cmd msg
    , route : Route
    , flags : Flags
    }
    -> ( Global.Model, Cmd Global.Msg, Cmd msg )
init _ =
  ( { isSignedIn = False }
  , Cmd.none
  , Cmd.none
  )

Initially, our layout has access to a record with three fields:

  • navigateTo - allows programmatic navigation to other pages.

  • route - the current route

  • flags - the initial JSON passed in with the app.

For our example, we set isSignedIn to False, don't perform any Global.Msg side effects, nor use messages.navigateTo to change to another page.

update

update :
    { navigateTo : Route -> Cmd msg
    , route : Route
    , flags : Flags
    }
    -> Global.Msg
    -> Global.Model
    -> ( Global.Model, Cmd Global.Msg, Cmd msg )
update { navigateTo } msg model =
  case msg of
    Global.SignIn ->
      ( { model | isSignedIn = True }
      , Cmd.none
      , navigateTo Route.Homepage
      )

    Global.SignOut ->
      ( { model | isSignedIn = False }
      , Cmd.none
      , navigateTo Route.SignIn
      )

In addition to the record we saw earlier with init, our layout's update function take a Global.Msg and Global.Model:

That allows us to return an updated state of the app, and programmatically navigate to different pages!


uh... still writing the docs 😬

dont look at me... dont look at me!!!