mirror of
https://github.com/ryan-haskell/elm-spa.git
synced 2024-11-26 03:08:02 +03:00
begin work on from scratch guide
This commit is contained in:
parent
a9f0df576b
commit
b910d88952
503
examples/from-scratch/README.md
Normal file
503
examples/from-scratch/README.md
Normal file
@ -0,0 +1,503 @@
|
||||
# from scratch
|
||||
> a guide on how to use elm-spa without the cli
|
||||
|
||||
## getting setup
|
||||
|
||||
1. Install [elm](https://guide.elm-lang.org/install/elm.html)
|
||||
|
||||
1. Optional: Install [VS Code](https://code.visualstudio.com/download) and the [elm extension](https://marketplace.visualstudio.com/items?itemName=Elmtooling.elm-ls-vscode)
|
||||
|
||||
## creating a new project
|
||||
|
||||
Create a new Elm project using the `elm init` command:
|
||||
|
||||
```terminal
|
||||
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.
|
||||
|
||||
```elm
|
||||
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
|
||||
|
||||
1. A unique key used for navigation
|
||||
|
||||
1. The global state of our app
|
||||
|
||||
1. The state of the page we are currently viewing
|
||||
|
||||
```elm
|
||||
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.
|
||||
|
||||
```elm
|
||||
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
|
||||
|
||||
```elm
|
||||
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.
|
||||
|
||||
1. The URL changes and we need to initialize the new page.
|
||||
|
||||
1. We received an update for the global state between pages.
|
||||
|
||||
1. 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:
|
||||
|
||||
```elm
|
||||
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.
|
||||
1. A way to upgrade a `Global.Msg` into a `Msg`.
|
||||
1. 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`
|
||||
1. 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!
|
||||
|
||||
```elm
|
||||
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](#todo).
|
||||
|
||||
## src/Document.elm
|
||||
|
||||
Before we get into `Global` and `Pages`, let's define the `Document` module, which defines what our pages should be returning:
|
||||
|
||||
```elm
|
||||
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.
|
||||
|
||||
```elm
|
||||
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
|
||||
|
||||
```elm
|
||||
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
|
||||
|
||||
```elm
|
||||
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`:
|
||||
|
||||
```elm
|
||||
send : Msg -> Cmd Msg
|
||||
send msg =
|
||||
Task.succeed msg |> Task.perform identity
|
||||
```
|
||||
|
||||
From there, we can expose commands easily:
|
||||
|
||||
```elm
|
||||
navigateTo : Route -> Cmd Msg
|
||||
navigateTo route =
|
||||
send (NavigatedTo route)
|
||||
```
|
||||
|
||||
```elm
|
||||
increment : Cmd Msg
|
||||
increment =
|
||||
send Increment
|
||||
```
|
||||
|
||||
|
||||
### view + subscriptions
|
||||
|
||||
Our view function received the three pieces of information we passed in from `Main.view`.
|
||||
|
||||
```elm
|
||||
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`!
|
||||
|
||||
```elm
|
||||
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](#todo).
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
```elm
|
||||
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
|
||||
|
||||
```elm
|
||||
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`.
|
||||
|
||||
```elm
|
||||
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:
|
||||
|
||||
```elm
|
||||
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.
|
Loading…
Reference in New Issue
Block a user