Skip to content

qfpl/elm-workshop

Repository files navigation

Elm Workshop

Elm Motivations

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!

Setup

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

Workshop Code Checkout

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

Install nodejs and npm

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

Install Elm & Elm Format

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.

Editor Setup

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.

Intellij

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.

Spacemacs

  • 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.

kakoune

  • Elm syntax highlighting comes standard. No compliation though, so you’ll have to watch the parcel watcher for errors.

  • To setup formatting you need to link it to the formatter command:

      hook global WinSetOption filetype=elm %{
        set window formatcmd 'elm-format --stdin'
      }

Install docker and start the backend:

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.

Frontends Setup

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!

Project Layout

Critical Pieces

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

  • 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.

Stuff that we’ll use later

  • 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

Elm Basics

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

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

Exercise Structure

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

Exercise 0: Example Exercise

💡 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.

How to ask questions or give feedback

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! 😄

Exercise 1: Login Form

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.

Exercise 1.1: Basic Form

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:

  • That we’re importing thing from elm/html in a qualified manner:

  • That every element (e.g H.form) has a list of attributes (that can be attributes like placeholder or events like onClick that we’ll see shortly) and then a list of child elements.

  • We put text nodes into the dom with H.text.

Exercise 1.2: Form Model

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 and loginPassword : 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 and SetLoginPassword to the Msg sum type so that we can handle this changes to our update 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 new Msg constructors (i.e SetLoginPlayerId and SetLoginPassword). If you forgot the String in the constructors you’ll get an error like Expected (String → Msg) but got Msg.

  • Hook up HA.value to the model.loginPlayerId and model.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 the playerId / 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 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.

Exercise 1.3: Sending the backend call off

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 the Msg type.

  • Hook HE.onSubmit on the form element to the LoginSubmit Msg.

  • On the update function, add the LoginSubmit 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.

  • 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 our update function).

  • Update the view function to display the backendError 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.

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".

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

  • 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.

Exercise 3: Remote Data Pattern

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.

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

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

Exercise 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

Appendixes

Backend API

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.

Unauthenticated Calls

POST /api/login (postApiLogin)

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.

POST /api/players (postApiPlayers)

This registers a new user. 400 if the user already exists. Return a session token for use on authenticated calls (just like login).

Authenticated Calls

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

GET /api/lobby (getApiLobby)

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.

POST /api/lobby (postApiLobby)

Appends a new chat line to the lobby on behalf of the player.

GET /api/games/joinable (getApiGamesJoinable)

Lists the games that are waiting for players.

POST /api/games (postApiGames)

Creates a new game owned by the logged in user.

GET /api/games/:game_id (getApiGamesByGameId)

Gets the current state of the game as per the perspective of the logged in player.

POST /api/games/:game_id:/events (postApiGamesByGameIdEvents)

Appends a new event to the game (chat or a player making a game action) on behalf of the logged in player. Returns nothing.

GET /api/games/:game_id:/events (getApiGamesByGameIdEvents)

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.

About

Workshop for getting into Elm

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •