Skip to content

gwils/elm-workshop

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Elm Workshop

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 ridiculously satirically FP themed and is not to be taken seriously, but should be a lot of fun to write. :)

Elm Motivations

Elm is a statically typed, purely functional programming language for building browser frontends. 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).

Given that it is the most lightweight purely functional UI library, it's worth learning for the sake of learning to teach some FP concepts that you can take back to typescript. 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!

Setup

You'll need a few things before you can start:

  • Checkout this code to your machine:
    • git clone --recurse-submodules https://github.com/benkolera/elm-workshop.git
  • Install nodejs and npm
  • Your favourite text editor
    • If you use vscode, installing the elm plugin is super handy
    • If you set formatOnSave to be true in vscode, having elm format installed is awesome npm install -g elm-format
  • Install docker and start the backend:
  • Start the frontend dev server:
    • run npm install in this directory
    • run npm run dev to get the dev server running.
    • Visit http://localhost:1234 and you should see a happy page!
    • Open up src/Main.elm in vscode 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 can login with "user1" / "pass" everything is all good! :)

You're all set to go at this point!

Project Layout

  • 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
    • src/fonts : Some gratuitous fonts for the app
  • 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:
    • src/Utils.elm : Some handy functions missing from the core libs
    • src/Route.elm : Route types that we'll use later
    • src/Page.elm : Page Abstraction that we'll use later
    • src/Session.elm : Player session
  • 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.
  • prerefactor/ : This is a copy of the app right before the refactor. Use this as your "answer" up until the refactoring step.

Elm Basics

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/

How to engage with this workshop

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 later if you are keen.

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 designs. It is still for Elm 0.18 so there will be some things that don't compile. Sing out for help if you hit things like that. 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.

What is really helpful is that you can stop the frontend server (the npm run dev thing) and then run the fully implemented one. They both talk to the same database, so it's nice to be able to see what the real one does. Lean on this if my descriptions are confusing.

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.

We're going to do things in a simple single-file fashion first then refactor later once the basics are down.

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.

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.

Exercise 1: Login Form

The first thing that we are going to do is make the login form and have that submitting to the backend.

Remember that you can reread https://guide.elm-lang.org/architecture/forms.html if this doesn't make sense yet.

Basic Form

We'll take it easy at this point. Replace the entire view with this snippet:

    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. Be sure at this point that you grok what all that Html stuff actually is. It'll start getting harder if you don't understand why things are like this.

Form Model

Lets wire the form up to some model state.

  • Add a loginPlayerId : String and loginPassword : String to the model record.
  • Don't forget to initialise these to empty string in the init function.
  • Add a SetLoginPlayerId and SetLoginPassword to the Msg sum type
  • Hook up HE.onInput to the new msgs
  • Hook up HA.value to the model.loginPlayerId and model.loginPassword
  • Implement each new case branch in the update function to set the playerId / password to the right spot in the model. If you forget to do this, you'll get a nice compilation error.

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 Msg type and the model gets updated in the update function. This is the core of "The Elm Architecture" so it's important to be pretty clear on this.

Sending the backend call off

Lets hook into Form HE.onSubmit to make the backend call when the user presses enter or clicks login.

  • Add a LoginSubmit to the Msg.
  • Hook HE.onSubmit on the form element to the LoginSubmit Msg.
  • On the update function, add the Submit 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 the LoginSubmit handler.
  • Write some new view that displays the loginError if the Maybe has a value (Pattern matching is fine or you can use Utils.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.

playerId / password that are already in the database ready for testing are user1 .. user5 with the password "pass".

Exercise 2: Register Form

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, SetRegisterPlayerId, SetRegisterPassword, SetRegisterPasswordAgain.

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 = [], token = Nothing }
                    , BE.postApiPlayers dbPlayer HandleRegisterResponse
                    )

                Err problems ->
                    ( { model
                        | registerValidationIssues = problems
                        , token = Nothing
                      }
                    , Cmd.none
                    )

Finish make 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.

Exercise 3: Remote Data Pattern

There's a pattern emerging here for a piece of data that isn't loaded initially and can fail error fetching it. There's a lovely abstraction for this called the RemoteData pattern.

https://package.elm-lang.org/packages/krisajenkins/remotedata/latest/RemoteData

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 is RemoteData.mapError and `Utils.httpErrorToStr that you'll need to 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 Ben 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.

Exercise 4: Lobby Chat Box

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.

A global Session

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 make a player : Maybe Session.Player key. It's pretty dodgy, but in our Handle{Login/Register}Resp we can just cram in the token from there over whatever maybe we already have. 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 you get the handle responses setting the player, then you should see the page switch over once login has happened. Now we can build our chat ui! :)

Hooking up the form

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. :)

Getting the Chat Lines

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. ;)

Exercise 5: New Game / Join Game

Exercise 6: Refactor & Routing

Game State

Excercise 7: Waiting For Players State

Exercise 8: Pregame State

Exercise 9: Rounds - Propose Team State

Exercise 10: Rounds - Team Vote State

Exercise 10: Rounds - Project Vote State

Exercise 11: Firing Round

Exercise 12: Completed

About

Workshop for getting into Elm

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Elm 91.2%
  • CSS 8.2%
  • Other 0.6%