This workshop is designed to step you through the basics of elm and progress through to making an SPA like thing.
To do this, we are going to implement a frontend for the board game Avalon (against a preexisting backend). This clone is farcically FP themed and is not to be taken seriously, but should be a lot of fun to write. :)
- Elm Motivations
- Setup
- Project Layout
- Elm Basics
- How to engage with this workshop
- Exercise Structure
- How to ask questions or give feedback
- Exercise 1: Login Form
- Exercise 2: Register Form
- Exercise 3: Remote Data Pattern
- Exercise 4: Lobby Chat Box
- Exercise 5: New Game / Join Game
- Exercise 6: Refactor & Routing
- Game State
- Appendixes
Elm is a statically typed, purely functional programming language for building browser frontends. It is the simplest possible model that allows you to make a UI in a purely functional way. It has an extremely high safety to weight ratio and gives a lot of safety over something like typescript + react + redux (which results in a pretty similar program shape). For this reason, it’s worth learning for the sake of learning to teach some FP concepts that you can take back to your other frameworks. It’s also very capable of producing really rich frontends, but it can be a lot of work if your app needs to talk to a lot of Javascript API (e.g Google Maps).
If things compile, you have a very low chance of your code crashing modulo a few interop things. It also creates very small payloads and the compiler is optimised for tight feedback loops without waiting on the compiler too much, which makes it a very attractive option!
You’ll need a few things before you can start:
Don’t forget the submodule part if you want the final version of the code! :)
git clone --recurse-submodules https://github.com/qfpl/elm-workshop.git
If you don’t already have node and npm, please follow the instructions for your OS: https://nodejs.org/en/download/package-manager/. If you are on nixos you’ll want nodejs nodePackages.npm
from pkgs.
If you haven’t dealt with NPM before, it is a good idea to have npm setup to install global things into your home directory rather than in a system location where you need to install things with sudo: https://github.com/sindresorhus/guides/blob/master/npm-global-without-sudo.md
If you aren’t on nixos:
npm i -g elm
npm i -g elm-format # (highly recommended for it to auto format your code while you are learning).
If you are on nixos you’ll want elmPackages.elm elmPackages.elm-format
from pkgs.
Visual Studio Code is the option with the highest power to weight ratio where weight is defined in terms of download size and setup difficulty. You’ll have something that reformats code as you save and shows compiler errors in the code if you follow these steps.
-
Install vscode via your package manager or via: https://code.visualstudio.com/Download
-
Install the elm plugin for syntax highlighting and compilation checks in editor.
-
If you set formatOnSave to be true in vscode for vscode to auto elm-format on save. Otherwise you can just run the format command periodically with C-P.
This method is probably preferrable if you like intellij. It gets to the same level as vscode just with a bigger download size.
-
Install the community edition of idea via your package manager or via: https://www.jetbrains.com/idea/download/ . The ultimate edition is fine too.
-
Open up an elm file and it’ll walk you through setting up the elm plugin, finding elm, elm-format and the elm.json.
-
Install the elm layer, everything should just work if elm and elm-format are on your path. You may want to enable elm-format-on-save-mode with importing the layer like this
(elm :variables elm-sort-imports-on-save t)
. You’ll need the generic syntax checking layer on to get compilation errors in spacemacs.
On most systems, you can just follow along with: https://docs.docker.com/install/
On nixos, setting virtualisation.docker.enable = true
and a nixos-rebuild should do the trick. Alternatively, with nix you could just run the backend in nix-shell --run "cabal new-run backend"
from ./dissidence/backend
if you are comfortable running haskell code via cabal.
To test the backend, run docker run -ti -p8001:8001 qfpl/dissidence-backend:latest
and you should see Starting server on port 8001
.
There are two frontends that you probably want to have setup and running. The workshop code that you’ll be editing in this directory ./src
at http://localhost:1235 and the frontend in the completed app in ./dissidence/frontend/
on http://localhost:1234 (so that you can see the final result and test that you are doing the right thing).
-
Start the workshop server:
-
run
npm install
in this directory to install parcel -
run
npm run dev
to get the parcel dev server running. -
Visit http://localhost:1235 and you should see a happy page! If you see a blank page, just ctrl-c the server and restart it (this often happens on the first run).
-
Open up src/Main.elm in your editor and change the text. Your page should be automatically reloaded!
-
-
Start the fully implemented app:
-
Control-C the dev server from before
-
cd ./dissidence/frontend
-
run
npm install
-
run
npm run dev
to get the dev server running. -
Visit http://localhost:1234 and you should see a login page (If you see a blank page, restart)! If you can login with "user1" / "pass" everything is all good! :)
-
You’re all set to go at this point!
-
package.json : Specifies our elm compiler and parcel version and the scripts to run parcel for us
-
elm.json : The elm dependencies that our UI will use.
-
The following pieces are bundled together with parcel (the thing that gets run when you
npm run dev
):-
src/index.html : The html file that is the basis of our SPA
-
src/app.scss : A premade sass stylesheet for the app
-
src/index.js : The javascript
-
./src/Main.elm : The main elm entrypoint. This is what you’ll be editing for the first exercise!
-
src/fonts/ : Some gratuitous fonts for the app
-
-
dissidence : This submodule is my fully finished version of the app. Go hunting in there for hints or just explore and tinker with it it if you prefer that to doing the workshop.
-
Answers : These directories contain the code as of that exercise for you to peek at if you are confused about something or need a clue.
-
api/Generated/Api.elm : This is an autogenerated set of backend calls that our app can call. It is generated by servant-elm. You would only care about servant-elm if you have a backend written in haskell, as it means that your UI and backend routes and types are much more easily kept in sync. If you don’t have a haskell backend, you’d probably write that file by hand.
-
Stuff that we’ll get to later:
-
Utils.elm : Some handy functions missing from the core libs
-
Route.elm : Route types that we’ll use later
-
Page.elm : Page Abstraction that we’ll use later
-
Session.elm : Player session
-
TODO: Add basic outline about elm architecture and terminology that I use in this workshop.
It’s a good idea to learn the basics of elm syntax and ideas. https://guide.elm-lang.org/ is an excellent start. You should read the following sections: - Core Language - The Elm Architecture - Types - Error Handling - HTTP - Time
Keep this open, as well as: https://package.elm-lang.org/packages/elm/core/1.0.2/
There is way too much to get done in a single sitting. Don’t feel daunted by this: the idea is that the workshop starts you off and gets you moving and you can finish the rest in other sittings.
If this workshop feels too much of a deep end for you, check out https://elmprogramming.com/ for a much slower paced intro that explains the motivations behind certain functional designs. It is still for Elm 0.18 so there will be some things that don’t compile. Contact me if you get stuck with anything via a github issue or on IRC (see the next section). Always check the docs for the libary at package.elm-lang.org.
If you are ready to write the SPA, check out the description of the app in dissidence/README.md. We’ll step by step build this starting page into a full implementation of the frontend for the game. This is definitely a challenge, but the backend does most of the heavy lifting of maintaining the game state.
Having the fully complete frontend (on port 1234) and your workshop code (on port 1235) is a super handy thing to do as it means that you can check your behaviour against the expected behaviours. They both talk to the same backend.
The page should live reload as you make changes and you should even see compile errors in the browser window. It’s a good idea to keep that window visible so you get constant and quick feedback. If you are using an editor
We’re going to do things in a simple single-file fashion first then refactor later once the basics are down. It’s going to feel like we are in a bad place just before the refactor, but I specifically want to start things off simple then motivate the more complicated Page Components later.
Follow the types. Have a look at what type the backend call needs and then figure out the UI bits that you need to get that together. Because the code in api/Generated/Api.elm is auto generated, it’s sadly lacking in documentation. Check out the [Backend API](#backend-api) for API details as you need them.
When you need to debug something, using Debug.log "label for log" value
will console.log that value to the console when the code is run. Handy if something is doing something unexpected. You can use that anywhere.
The exercises should all follow the following form (the hint block and ending note are optional, but they’ll always be something at least to do):
💡 Tip
|
The tip section of an exercise may give you a prompt to something that you may need to read if you get stuck. |
The things to do will be normal unformatted text and code.
📝 Note
|
The note section will try to list out the things that you should have learned in this exercise so that you can check that you’re progressing and didn’t miss something along the way. |
Jump into the QFPL room on freenode.net: Freenode Webchat.
File an issue against this repository.
PRs welcome to make suggestions on adding/removing/modifying workshop content! 😄
The first thing that we are going to do is make the login form and make that submit to the backend.
💡 Tip
|
Remember that you can reread https://guide.elm-lang.org/architecture/forms.html if this doesn’t make sense yet. You can also peek at the Answers if you get stuck. |
We’ll take it easy at this point. Replace the entire view with this snippet. This doesn’t actually link anything up to our state yet. It’s just some form markup that does nothing, but it’s important for us to take it slowly!
H.div [ HA.class "login-box" ]
[ H.h1 [] [ H.text "Login" ]
, H.form []
[ H.input
[ HA.placeholder "Player Id"
, HAA.ariaLabel "Player ID"
]
[]
, H.input
[ HA.placeholder "Password"
, HA.type_ "password"
, HAA.ariaLabel "Password"
]
[]
, H.button
[ HA.class "btn primary" ]
[ H.text "Login" ]
]
]
Your browser should reload as soon as you save this, showing a form that doesn’t do anything. We’ll make it do stuff in the next step.
📝 Note
|
Be sure at this point that you grok what all that Html stuff actually is. You’ll want to understand:
|
Lets wire the form up to some model state and get a feel for how events flow from user interactions with our view to our update function.
💡 Tip
|
There is no state that we can query of a form element in Elm. All we can do is listen to an event when the user changes the value and then store that new value in our model. We do this through having constructors in our message to handle certain form fields. |
-
Add a
loginPlayerId : String
andloginPassword : String
to the model record so that we can store the player id and the password from the form somewhere. -
Don’t forget to initialise these to empty string in the
init
function. -
Add a
SetLoginPlayerId
andSetLoginPassword
to the Msg sum type so that we can handle this changes to ourupdate
function. These constructors should take a string to pass along the new value from the event. -
Hook up
HE.onInput
on each form element to the newMsg
constructors (i.eSetLoginPlayerId
andSetLoginPassword
). If you forgot theString
in the constructors you’ll get an error likeExpected (String → Msg) but got Msg
. -
Hook up
HA.value
to themodel.loginPlayerId
andmodel.loginPassword
so that the value properly flows back down from our model to the form element. -
Implement each new case branch in the
update
function to set theplayerId
/password
to the right spot in the model. If you forget to do this, you’ll get a nice compilation error.
📝 Note
|
At this point you should be feeling fairly comfortable with making a basic form and saving the state of that input into the model. Getting the shape of how your dom events flow in via your |
Lets hook into the form’s HE.onSubmit
to make the backend call when the user presses enter or clicks login.
-
Add a
LoginSubmit
to theMsg
type. -
Hook
HE.onSubmit
on the form element to theLoginSubmit
Msg. -
On the
update
function, add theLoginSubmit
handler which fires off to the backend.. -
Replace the command in the initialiser with
Cmd.none
-
Put the call to
BE.postApiLogin
into the cmd of theLoginSubmit
handler. -
Add a
token : Maybe String
field to the model to store our auth token (if we have one). -
Update the
token
field when we get a response to our login (HandleLoginResp
branches in ourupdate
function). -
Update the
view
function to display thebackendError
if theMaybe
has a value (Pattern matching is fine or you can useUtils.maybe
).
This wont do much, but it will print an error if the user doesn’t authenticate and do nothing if it is good. That’s OK for now. At this point you should now see how we can fire off effects in our update and how the return from our backend call comes in a later msg HandleLoginResp
.
It’s worth calling out that we’re not doing anything with our token yet. That comes later. For now, we’re only interested in keeping track of what comes back.
playerId
/ password
that are already in the database ready for testing are user1 .. user5 with the password "pass".
Lets do the same form, but for a register page that takes the password twice and makes sure that they are equal.
We’ll just put the register underneath the login for now all on the one page (you’ll have to wrap things in another div at the top level).
Add new model fields and message constructors for:
-
registerPlayerId
-
registerPassword
-
registerPasswordAgain
-
registerValidationIssues
-
registerToken
-
registerError
-
SetRegisterPlayerId
-
SetRegisterPassword
-
SetRegisterPasswordAgain
.
We should also rename backendError
to loginError
and token
to loginToken
to make things clear and consistent.
Our backend call for registering a new user is:
postApiPlayers : DbPlayer -> (Result Http.Error (String) -> msg) -> Cmd msg
type alias DbPlayer =
{ dbPlayerId: PlayerId
, dbPlayerPassword: String
}
On our submit handler, we want to check whether the passwords match and only call the register call if they match.
A good place to start is to write a function with this signature:
validateDbPlayer : Model -> Result.Result (List String) BE.DbPlayer
validateDbPlayer model =
So it takes the model and returns either a list of errors or a valid DbPlayer
ready to submit to the backend. Try writing this with a simple if statement and checking whether model.registerPassword == model.registerPasswordAgain
.
Once we have that function written we can then change our update submit handler to look like:
Submit ->
case validateDbPlayer model of
Ok dbPlayer ->
( { model | registerValidationIssues = [], registerToken = Nothing }
, BE.postApiPlayers dbPlayer HandleRegisterResp
)
Err problems ->
( { model
| registerValidationIssues = problems
, registerToken = Nothing
}
, Cmd.none
)
Finish making sure the new view is all wired up and does what you’d expect. At this point you should be pretty comfortable creating new UI / model / msgs from scratch.
There’s a pattern emerging here for a piece of data that isn’t loaded initially and can fail when fetching it. There’s a lovely abstraction for this called the RemoteData
pattern.
So now we can say in our model:
-- Instead of these:
-- loginToken : Maybe String
-- loginError : Maybe String
-- We have this:
, loginToken : RemoteData String String
Which means that our login token is of four possible shapes:
-
NotAsked
(We haven’t done the backend call for it yet) -
Loading
(The backend call is in progress and we could show a spinner, disable a form, etc.) -
Success String
(The token is loaded and ready to go) -
Failure String
(The token failed to load and we have an error message)
This is the same as our two eithers, but we now also get feedback when the backend call is in progress, which is very handy!
Change loginToken
and registerToken
to be RemoteData String String
and make the necessary changes as per your compilation errors. There are RemoteData.mapError
, Utils.remoteDataError
, and Utils.httpErrorToStr
that might help you make these changes. Be sure to set it to Loading
just as the backend calls are sent off.
Change the view
to pattern match on the RemoteData
constructors to print out NotAsked
/Loading
/Success
/Failure
output.
As always, you can check in with prerefactor/Main.elm
or ask for help if you need hints.
At this point you should be comfy with how we can track our remote data and how to convert from Http
results to the RemoteData
shape.
A big part of our app is having a chat where all the players can negotiate and bluff their way to victory. This exercise will be creating the chat widget for the lobby where players can chat before joining a game. This will get us doing some views over lists of things and also have us calling the backend periodically.
To submit a chat entry, we’re going to need a user token, so we’ll have to put that somewhere nice. It feels pretty clunky at the moment whacking everything into the one Model/Message/View, but we’ll stick with this for one more exercise before we introduce an abstraction that may make things harder.
The string that the login / register backend calls return is actually a base64 encoded JWT. We’re going to need to store that in a central place alongside the actual player id so that we can display that to the user.
The Session.Player
type is meant for this. It also has a json decoder / encoder pair so that we can store the session into local storage if we want to persist the login across page reloads.
In the Model
, lets add a player : Maybe Session.Player
field. It’s pretty dodgy, but in our Handle{Login/Register}Resp
updates we can just cram in the token we receive on success into player
. RemoteData.toMaybe
is your friend here!
Rename the current view function to loggedOutView
and start with this new view code:
view : Model -> H.Html Msg
view model =
case model.player of
Nothing ->
loggedOutView model
Just p ->
loggedInView p model
loggedInView : Session.Player -> Model -> H.Html Msg
loggedInView player model =
-- This is just some boilerplate markup
H.div [ HA.class "lobby" ]
[ H.div [ HA.class "lobby-games" ]
[ H.h1 [] [ H.text "Lobby" ]
]
, H.div [ HA.class "chatbox-container" ]
[ H.h2 [] [ H.text "Chat Lobby" ]
, H.div [ HA.id "chatbox", HA.class "chatbox" ] []
, H.form [ ]
[ H.ul []
[ H.li [ HA.class "chat-message" ]
[ H.input
[ HA.placeholder "type a chat message"
, HA.class "chat-message-input"
, HAA.ariaLabel "Enter Chat Message"
]
[]
]
, H.li []
[ H.button
[ HA.class "btn primary" ]
[ H.text "send" ]
]
]
]
]
]
loggedOutView : Model -> H.Html Msg
loggedOutView model = ...
Once the player
has been set successfully you should see the page switch over once login has happened. Now we can build our chat ui! :)
The elements of the form to have a user enter their chat message are all there. You need to get chat line to
postApiLobby : Token -> String -> (Result Http.Error (()) -> msg) -> Cmd msg
Do the dance of hooking up the input to set the string into the model, an on submit and then making the backend call.
Hopefully that doesn’t bug you too much, but there is some really annoying bits where we need the player session. It should be starting to get painfully obvious that we need a better structure. :)
Now we need to plug in to our ability to subscribe to things from the outside world based on some state in our model. These subscriptions are recalculated every time the model is changed.
If we are logged in, we want to look for new chat lines every two seconds. That looks a little like this:
subscriptions : Model -> Sub Msg
subscriptions model =
case model.player of
Nothing ->
Sub.none
Just p ->
Time.every 2000 (Tick p)
You can’t do effects in your subscription. The subscription only gives your app back a msg and you have to do you effects in the update function as per always. Elm strictly keeps your side effects to init and update.
Please create the Tick constructor, a handler in update for it that calls BE.getApiLobby with the token in the session and Nothing for the time stamp (we’ll deal with that later).
Now you need to write the view to draw the chat lines out.
Note that the child elements of an element are just a (List H.Html Msg), so if you write this function:
chatLineView : BE.ChatLine -> H.Html Msg
chatLineView cl = H.text "implement me"
You can use this with List.map chatLineView model.chatLines
to get a list of children from the model list.
Ideally at this point you can submit a chat line and have it pop up up to 2 seconds later. If you open up two browser windows you can even chat to yourself if you’d like. ;)
This section is to help you figure out the shape of the backend calls. It’s manually created, so there is a chance that it may get out of date with the actual API and types. Always trust the types over the documentation if there is a disagreement.
This makes a login request to the backend. It returns the user session token that needs to be provided to requests that require the user to be logged in (the auth_token parameters that you see in other calls). Otherwise it’ll return a 401 if the login failed.
All of these calls take a header_authorization Token and either act or have the game state filtered as per the perspective of the player. The thing that needs to be supplied as the token is the result of the login / register call (which is just a string).
There is a chat room that is not attached to a game that is used for chatting to arrange a game. This call returns all of the chat lines for that lobby. It also takes an optional posix time. If not supplied, it’ll grab all of the chat lines, otherwise it’ll grab only chat lines after the time specified.
Gets the current state of the game as per the perspective of the logged in player.
Appends a new event to the game (chat or a player making a game action) on behalf of the logged in player. Returns nothing.
Gets all of the game events as seen by the logged in player (some information will be hidden because not all players can see all the game state). This optionally takes a posix time. If not supplied, it’ll grab all of the events, otherwise it’ll grab only events after the time specified.