elm-spa/README.md

509 lines
11 KiB
Markdown
Raw Normal View History

2019-10-05 01:16:02 +03:00
# ryannhg/elm-app
> a way to build single page apps with Elm.
2019-10-08 08:22:17 +03:00
## 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?
- A working demo is available online here: [https://elm-app-demo.netlify.com/](https://elm-app-demo.netlify.com/)
- And you can play around with an example yourself in the repo: [https://github.com/ryannhg/elm-app/tree/master/examples/basic](https://github.com/ryannhg/elm-app/tree/master/examples/basic) around with the files in `examples/basic/src` and change things!
---
## examples are helpful!
Let's walk through the package together, at a high-level, with some code!
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
### src/Main.elm
2019-10-05 01:16:02 +03:00
```
2019-10-08 08:22:17 +03:00
our-project/
elm.json
src/
Main.elm ✨
2019-10-05 01:16:02 +03:00
```
2019-10-08 08:22:17 +03:00
This is the __entrypoint__ to the application, and connects all the parts of our `Application` together:
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
```elm
module Main exposing (main)
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
import Application
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
main =
Application.create
{ routing = -- TODO
, layout = -- TODO
, pages = -- TODO
}
```
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
As you can see, `Application.create` is a function that takes in a `record` with three properties:
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
1. __routing__ - handles URLs and page transitions
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
2. __layout__ - the app-level `init`, `update`, `view`, etc.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
3. __pages__ - the page-level `init`, `update`, `view`, etc.
2019-10-05 01:16:02 +03:00
2019-10-05 01:18:12 +03:00
2019-10-08 08:22:17 +03:00
#### routing
2019-10-05 01:16:02 +03:00
```elm
module Main exposing (main)
2019-10-08 08:22:17 +03:00
import Application
import Route ✨
2019-10-05 01:16:02 +03:00
main =
2019-10-08 08:22:17 +03:00
Application.create
{ routing =
{ fromUrl = Route.fromUrl ✨
, toPath = Route.toPath ✨
, transitionSpeed = 200 ✨
2019-10-05 01:16:02 +03:00
}
2019-10-08 08:22:17 +03:00
, layout = -- TODO
, pages = -- TODO
}
2019-10-05 01:16:02 +03:00
```
2019-10-08 08:22:17 +03:00
The record for `routing` only has three properties:
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
1. __fromUrl__ - a function that turns a `Url` into a `Route`
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
2. __toPath__ - a function that turns a `Route` into a `String` used for links.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
3. __transitionSpeed__ - number of __milliseconds__ it takes to fade in/out pages.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
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!
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
We'll link to that in a bit!
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
#### layout
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
```elm
module Main exposing (main)
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
import Application
import Route
import Components.Layout as Layout ✨
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
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
}
2019-10-05 01:16:02 +03:00
```
2019-10-08 08:22:17 +03:00
The `layout` property introduces four new pieces:
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
1. __init__ - how to initialize the shared state.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
2. __update__ - how to update the app-level state (and routing commands).
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
3. __view__ - the app-level view (and where to render our page view)
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
4. __subscriptions__ - app-level subscriptions (regardless of which page we're on)
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
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.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
#### pages
```elm
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 ✨
}
2019-10-05 04:25:25 +03:00
}
2019-10-08 08:22:17 +03:00
```
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
Much like the last property, `pages` is just a few functions.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
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.)
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
#### that's it for Main.elm!
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
As the final touch, we can update our import statements to add in a type annotation for the `main` function:
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
```elm
module Main exposing (main)
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
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
}
2019-10-05 04:25:25 +03:00
}
2019-10-08 08:22:17 +03:00
```
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
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!
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
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.
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
But enough of that let's move on to routing next!
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
---
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
### src/Route.elm
2019-10-05 04:25:25 +03:00
2019-10-05 01:16:02 +03:00
```
2019-10-08 08:22:17 +03:00
our-project/
elm.json
src/
Main.elm
Route.elm ✨
```
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
in our new file, we need to create a [custom type](#custom-type) to handle all the possible routes.
```elm
module Route exposing (Route(..))
type Route
= Homepage
2019-10-08 08:30:58 +03:00
| SignIn
2019-10-08 08:22:17 +03:00
```
2019-10-05 01:16:02 +03:00
2019-10-08 08:30:58 +03:00
For now, there is only two routes: `Homepage` and `SignIn`.
2019-10-05 01:16:02 +03:00
2019-10-08 08:30:58 +03:00
We also need to make `fromUrl` and `toPath` so our application handles routing and page navigation correctly!
2019-10-08 08:22:17 +03:00
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:
2019-10-05 01:16:02 +03:00
```elm
2019-10-08 08:22:17 +03:00
module Route exposing
( Route(..)
, fromUrl ✨
, toPath ✨
)
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
import Url exposing (Url) ✨
import Url.Parser as Parser exposing (Parser) ✨
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
type Route
= Homepage
2019-10-08 08:30:58 +03:00
| SignIn
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
fromUrl : Url -> Route ✨
-- TODO
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
toPath : Route -> String ✨
-- TODO
```
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
#### fromUrl
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
Let's get started on implementing `fromUrl` by using the `Parser` module:
```elm
type Route
= Homepage
2019-10-08 08:30:58 +03:00
| SignIn
2019-10-08 08:22:17 +03:00
| NotFound ✨ -- see note #2
fromUrl : Url -> Route
fromUrl url =
let
router =
2019-10-08 08:30:58 +03:00
Parser.oneOf ✨ -- see note #1
[ Parser.map Homepage Parser.top
, Parser.map SignIn (Parser.s "sign-in")
2019-10-08 08:22:17 +03:00
]
in
Parser.parse router url
|> Maybe.withDefault NotFound ✨ -- see note #2
```
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
__Notes__
2019-10-05 04:25:25 +03:00
2019-10-08 08:30:58 +03:00
1. With `Parser.oneOf`, we match `/` to `Homepage` and `/sign-in` to `SignIn`.
2019-10-08 08:22:17 +03:00
2019-10-08 08:24:14 +03:00
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!)
2019-10-08 08:22:17 +03:00
2019-10-08 08:30:58 +03:00
2019-10-08 08:22:17 +03:00
#### toPath
It turns out `toPath` is really easy, its just a case expression:
```elm
toPath : Route -> String ✨
toPath route =
case route of
Homepage -> "/"
2019-10-08 08:30:58 +03:00
SignIn -> "/sign-in"
2019-10-08 08:22:17 +03:00
NotFound -> "/not-found"
2019-10-05 04:25:25 +03:00
```
2019-10-08 08:24:45 +03:00
#### that's it for Route.elm!
2019-10-08 08:22:17 +03:00
here's the complete file we made.
```elm
module Route exposing
( Route(..)
, fromUrl
, toPath
)
import Url exposing (Url)
import Url.Parser as Parser exposing (Parser)
type Route
= Homepage
2019-10-08 08:30:58 +03:00
| SignIn
2019-10-08 08:22:17 +03:00
| NotFound
fromUrl : Url -> Route
fromUrl url =
let
router =
Parser.oneOf
[ Parser.map Homepage Parser.top
2019-10-08 08:30:58 +03:00
, Parser.map SignIn (Parser.s "sign-in")
2019-10-08 08:22:17 +03:00
]
in
Parser.parse router url
|> Maybe.withDefault NotFound
toPath : Route -> String
toPath route =
case route of
Homepage -> "/"
2019-10-08 08:30:58 +03:00
SignIn -> "/sign-in"
2019-10-08 08:22:17 +03:00
NotFound -> "/not-found"
```
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
You can learn how to add more routes by looking at:
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
1. __the `elm/url` docs__ - https://package.elm-lang.org/packages/elm/url/latest
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
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 ✨
```
2019-10-08 08:30:58 +03:00
For this app, we don't actually have flags, so we return an empty tuple.
2019-10-05 04:25:25 +03:00
```elm
2019-10-08 08:22:17 +03:00
module Flags exposing (Flags)
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
type alias Flags = ()
```
2019-10-08 08:30:58 +03:00
So let's move onto something more interesting!
2019-10-08 08:22:17 +03:00
---
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
### src/Global.elm
```
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm ✨
```
2019-10-08 08:30:58 +03:00
The purpose of `Global.elm` is to define the `Model` and `Msg` types we'll share across pages and use in our layout functions:
2019-10-08 08:22:17 +03:00
```elm
module Global exposing ( Model, Msg(..) )
2019-10-05 01:16:02 +03:00
type alias Model =
2019-10-08 08:22:17 +03:00
{ isSignedIn : Bool
2019-10-05 04:25:25 +03:00
}
2019-10-05 01:16:02 +03:00
2019-10-05 04:25:25 +03:00
type Msg
2019-10-08 08:22:17 +03:00
= SignIn
2019-10-05 04:25:25 +03:00
| SignOut
2019-10-08 08:22:17 +03:00
```
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
Here we create a simple record to keep track of the user's sign in status.
2019-10-05 01:16:02 +03:00
2019-10-08 08:30:58 +03:00
Let's see an example of `Global.Model` and `Global.Msg` being used in our layout:
2019-10-05 01:16:02 +03:00
2019-10-08 08:22:17 +03:00
### src/Components/Layout.elm
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
```
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm
Components/
Layout.elm ✨
```
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
To implement an app-level layout, we'll need a new file:
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
```elm
module Components.Layout exposing (init, update, view, subscriptions)
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
import Global
import Route exposing (Route)
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
-- ...
```
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
This file needs to export the following four functions:
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
#### init
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
```elm
init :
{ navigateTo : Route -> Cmd msg
, route : Route
, flags : Flags
}
-> ( Global.Model, Cmd Global.Msg, Cmd msg )
init _ =
( { isSignedIn = False }
, Cmd.none
, Cmd.none
)
```
2019-10-05 04:25:25 +03:00
2019-10-08 08:30:58 +03:00
Initially, our layout has access to a record with three fields:
2019-10-05 04:25:25 +03:00
2019-10-08 08:30:58 +03:00
- __navigateTo__ - allows programmatic navigation to other pages.
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
- __route__ - the current route
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
- __flags__ - the initial JSON passed in with the app.
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
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.
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
#### update
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
```elm
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
)
2019-10-05 04:25:25 +03:00
```
2019-10-08 08:30:58 +03:00
In addition to the record we saw earlier with `init`, our layout's `update` function take a `Global.Msg` and `Global.Model`:
2019-10-08 08:22:17 +03:00
2019-10-08 08:30:58 +03:00
That allows us to return an updated state of the app, and programmatically navigate to different pages!
2019-10-08 08:22:17 +03:00
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
---
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
## uh... still writing the docs 😬
2019-10-05 04:25:25 +03:00
2019-10-08 08:22:17 +03:00
dont look at me... dont look at me!!!