├── .gitignore ├── README.md ├── Step01 ├── HomePage.elm ├── README.md ├── Solution │ └── HomePage.elm └── Tests │ └── Tests.elm ├── Step02 ├── README.md ├── ResultPage.elm ├── Solution │ └── ResultPage.elm └── Tests │ └── Tests.elm ├── Step03 ├── README.md ├── ResultPage.elm ├── Solution │ └── ResultPage.elm └── Tests │ └── Tests.elm ├── Step04 ├── CategoriesPage.elm ├── README.md └── Solution │ └── CategoriesPage.elm ├── Step05 ├── CategoriesPage.elm ├── README.md ├── Solution │ └── CategoriesPage.elm └── Tests │ └── Tests.elm ├── Step06 ├── README.md ├── Solution │ └── UserStatus.elm ├── Tests │ └── Tests.elm └── UserStatus.elm ├── Step07 ├── README.md └── RandomNumber.elm ├── Step08 ├── CategoriesPage.elm ├── README.md ├── Solution │ └── CategoriesPage.elm └── Tests │ └── Tests.elm ├── Step09 ├── CategoriesPage.elm ├── README.md ├── Solution │ └── CategoriesPage.elm └── Tests │ └── Tests.elm ├── Step10 ├── README.md ├── Routing.elm ├── Solution │ └── Routing.elm └── Tests │ └── Tests.elm ├── Step11 ├── ParsingRoute.elm ├── README.md ├── Solution │ └── ParsingRoute.elm └── Tests │ └── Tests.elm ├── Step12 ├── GamePage.elm ├── README.md ├── Solution │ └── GamePage.elm └── Tests │ └── Tests.elm ├── Step13 ├── GamePage.elm ├── README.md ├── Solution │ └── GamePage.elm └── Tests │ └── Tests.elm ├── Step14 ├── GamePage.elm ├── Main.elm ├── README.md ├── Solution │ └── Main.elm └── Tests │ └── Tests.elm ├── Step15 ├── Main.elm ├── README.md ├── Solution │ ├── Api.elm │ ├── Main.elm │ ├── README.md │ ├── Routing.elm │ ├── Types.elm │ ├── Update.elm │ └── View.elm └── Tests │ └── Tests.elm ├── Utils ├── Utils.elm ├── bootstrap.min.css ├── images │ ├── step1.png │ ├── step12.png │ ├── step2.png │ ├── step3.png │ ├── step4.png │ ├── step6.png │ ├── step7-goal.png │ └── step7-tea.png ├── style.css └── test-style.css ├── elm.json └── syntax-help.md /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | .idea 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome! 2 | 3 | Today, we will try to learn Elm together. Before starting to develop in Elm, we need to follow some instructions to have a working dev environment. 4 | 5 | 6 | ## Install Elm 7 | 8 | You need to download and install Elm through [the following link](https://guide.elm-lang.org/install.html). 9 | 10 | ## Install a plugin for your editor 11 | 12 | Install a plugin for your editor to be able to understand Elm. You can find some instructions for these editors (I advice you to use IntelliJ or VS Code): 13 | 14 | - [Atom](https://atom.io/packages/language-elm) 15 | - [Brackets](https://github.com/tommot348/elm-brackets) 16 | - [Emacs](https://github.com/jcollard/elm-mode) 17 | - [Webstorm / IntelliJ](https://github.com/klazuka/intellij-elm) 18 | - [Sublime Text](https://packagecontrol.io/packages/Elm%20Language%20Support) 19 | - [Vim](https://github.com/ElmCast/elm-vim) 20 | - [VS Code](https://marketplace.visualstudio.com/items?itemName=Elmtooling.elm-ls-vscode) 21 | 22 | If you don't want to set up a dev environment, you can use the Ellie online editor – a link will be provided at each step. 23 | 24 | ## Install `elm-format` 25 | 26 | `elm-format` is a code formatter that will format your Elm code according to a standard set of rules. It looks a lot like `prettier`, for those who know it. This is not mandatory but strongly advised as it will really improve your experience with Elm. 27 | 28 | Follow the instructions on the [elm-format page](https://github.com/avh4/elm-format#installation-) and don't forget to configure your IDE to work with `elm-format`. 29 | 30 | ## Get the code 31 | 32 | You now need to get the code from this repository, either by [downloading the archive (click here)](https://github.com/jgrenat/elm-workshop/archive/master.zip) or by cloning it: 33 | 34 | ``` 35 | git clone https://github.com/jgrenat/elm-workshop.git 36 | cd elm-workshop 37 | ``` 38 | 39 | # Workshop 40 | 41 | This workshop is divided into several folders corresponding to one step each. To start, first execute the following commands into your terminal at the root of this repository: 42 | 43 | ```sh 44 | elm reactor 45 | ``` 46 | 47 | You can then open the link [http://localhost:8000/](http://localhost:8000/). As you can see, `elm-reactor` allowed us to launch a basic dev environment, and you can see the different folders for each step of the project. You can now start by going into the step 1. 48 | 49 | If you need a little reminder about the syntax, you can check [this cheat sheet](./syntax-help.md). 50 | -------------------------------------------------------------------------------- /Step01/HomePage.elm: -------------------------------------------------------------------------------- 1 | module Step01.HomePage exposing (homePage, main) 2 | 3 | import Html exposing (Html, a, div, h1, text) 4 | import Html.Attributes exposing (class, href, id) 5 | import Utils.Utils exposing (styles, testsIframe) 6 | 7 | 8 | homePage : Html msg 9 | homePage = 10 | div [] 11 | [ h1 [] [ text "A random title" ] 12 | , a [ class "btn", href "#nowhere" ] [ text "A random link" ] 13 | ] 14 | 15 | 16 | 17 | ------------------------------------------------------------------------------------------------------------------------ 18 | -- You don't need to worry about the code below, it only displays the result of your code and the result of the tests -- 19 | ------------------------------------------------------------------------------------------------------------------------ 20 | 21 | 22 | main = 23 | div [] 24 | [ styles 25 | , div [ class "jumbotron" ] [ homePage ] 26 | , testsIframe 27 | ] 28 | -------------------------------------------------------------------------------- /Step01/README.md: -------------------------------------------------------------------------------- 1 | # Quiz Game 2 | 3 | Great, your environment is now working! If that's not the case, read again the main [README](../README.md) and feel free to ask me for help! 4 | 5 | Now, you should be reading this on the url http://localhost:8000/Step01/. The interface you are seeing is powered by Elm Reactor, a tool to quickly have a dev environment started. The only drawback is that it does not handle auto-refresh, and you'll have to refresh yourself to see changes. 6 | 7 | The goal of this workshop is to create a quiz game based on the Trivia API. A few questions are retrieved from the API and the user can answer to them. A working version can be found here: https://trivia-game.surge.sh/ 8 | 9 | Step by step, we will discover the Elm language by implementing this web application. 10 | 11 | **Let's start!** 12 | 13 | ## Objectif 14 | 15 | In this first step, we will create the homepage. As you can see below, it should contain a title and two buttons. 16 | 17 | ![Screenshot of the homepage](../Utils/images/step2.png) 18 | 19 | Here is the HTML structure you should match in order to pass the tests: 20 | 21 | ```html 22 |
23 |

Quiz Game

24 | 25 | 26 | Play random questions 27 | 28 | 29 | 30 | Play from a category 31 | 32 |
33 | ``` 34 | 35 | You can now open the file `./HomePage.elm` in your IDE and start coding to make your tests pass. They should be quite explicit about what is expected, take some time to read them! 36 | 37 | 38 | ## In Elm, HTML is, well... Elm code! 39 | 40 | To display HTML in Elm, you will use standard Elm functions imported from a module called `Html`. As you can see, some of these functions are imported at the top of the file: 41 | 42 | ```elm 43 | import Html exposing (Html, a, div, h1, text) 44 | ``` 45 | 46 | You can then use them that way: 47 | 48 | ```elm 49 | div [] [] 50 | ``` 51 | 52 | As you can see, the function `div` takes two arguments that are two lists. 53 | 54 | The first list contains the HTML attributes you want to set on your `div` tag, for example an `id`, a `class`, ... 55 | 56 | The second list contains the children / content of your div element. Remember that a HTML file is a tree where tags contain other tags. 57 | 58 | To help you assimilate these notions, here are a few examples and their resulting HTML: 59 | 60 | ```elm 61 | div [ class "myClass" ] [] 62 | --
63 | 64 | 65 | div [] [ text "content of my div" ] 66 | --
content of my div
67 | 68 | 69 | div [] [ h1 [] [ text "a title inside a div" ] ] 70 | --

a title inside a div

71 | 72 | 73 | div [ id "myId" ] 74 | [ span [] [ text "two spans inside" ] 75 | , span [] [ text "a parent div" ] 76 | ] 77 | --
78 | -- two spans inside 79 | -- a parent div 80 | --
81 | 82 | ``` 83 | 84 | It's now your turn, good luck! 85 | 86 | 87 | ## Let's start! 88 | [Click here to view the result of your file in the browser](./HomePage.elm) (don't forget to **refresh** the page after changes!) 89 | 90 | 91 | Once the tests are passing, you can go to the [next step](../Step02). 92 | -------------------------------------------------------------------------------- /Step01/Solution/HomePage.elm: -------------------------------------------------------------------------------- 1 | module Step01.Solution.HomePage exposing (homePage) 2 | 3 | import Html exposing (Html, a, div, h1, text) 4 | import Html.Attributes exposing (class, href) 5 | 6 | 7 | homePage : Html msg 8 | homePage = 9 | div [ class "gameOptions" ] 10 | [ h1 [] [ text "Quiz Game" ] 11 | , a [ class "btn btn-primary", href "#game" ] [ text "Play random questions" ] 12 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 13 | ] 14 | -------------------------------------------------------------------------------- /Step01/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step01.Tests.Tests exposing (suite) 2 | 3 | import Expect 4 | import Html exposing (Html, div) 5 | import Html.Attributes exposing (href) 6 | import Random 7 | import Step01.HomePage exposing (homePage) 8 | import Test exposing (Test, concat, test) 9 | import Test.Html.Query as Query 10 | import Test.Html.Selector exposing (attribute, class, classes, tag, text) 11 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 12 | import Utils.Utils exposing (testStyles) 13 | 14 | 15 | main : Html a 16 | main = 17 | div [] 18 | [ testStyles 19 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 20 | ] 21 | 22 | 23 | suite : Test 24 | suite = 25 | concat 26 | [ divHasProperClassTest 27 | , titleIsPresent 28 | , twoLinksAreDisplayed 29 | , theTwoLinksHaveProperClasses 30 | , aLinkToGameIsPresent 31 | , theGameLinkHasProperText 32 | , aLinkToCategoriesIsPresent 33 | , theCategoriesLinkHasProperText 34 | ] 35 | 36 | 37 | divHasProperClassTest : Test 38 | divHasProperClassTest = 39 | test "The div should have a class 'gameOptions'" <| 40 | \() -> 41 | div [] [ homePage ] 42 | |> Query.fromHtml 43 | |> Query.find [ tag "div" ] 44 | |> Query.has [ class "gameOptions" ] 45 | 46 | 47 | titleIsPresent : Test 48 | titleIsPresent = 49 | test "There should be a h1 tag containing the text 'Quiz Game' (watch out, the case is important!)" <| 50 | \() -> 51 | homePage 52 | |> Query.fromHtml 53 | |> Query.find [ tag "h1" ] 54 | |> Query.has [ text "Quiz Game" ] 55 | 56 | 57 | twoLinksAreDisplayed : Test 58 | twoLinksAreDisplayed = 59 | test "Two links are displayed" <| 60 | \() -> 61 | homePage 62 | |> Query.fromHtml 63 | |> Query.findAll [ tag "a" ] 64 | |> Query.count (Expect.equal 2) 65 | 66 | 67 | theTwoLinksHaveProperClasses : Test 68 | theTwoLinksHaveProperClasses = 69 | test "The two links have the classes 'btn btn-primary'" <| 70 | \() -> 71 | homePage 72 | |> Query.fromHtml 73 | |> Query.findAll [ tag "a" ] 74 | |> Query.each (Query.has [ classes [ "btn", "btn-primary" ] ]) 75 | 76 | 77 | aLinkToGameIsPresent : Test 78 | aLinkToGameIsPresent = 79 | test "The first link goes to '#game'" <| 80 | \() -> 81 | homePage 82 | |> Query.fromHtml 83 | |> Query.findAll [ tag "a" ] 84 | |> Query.first 85 | |> Query.has [ attribute (href "#game") ] 86 | 87 | 88 | theGameLinkHasProperText : Test 89 | theGameLinkHasProperText = 90 | test "The first link has text 'Play random questions'" <| 91 | \() -> 92 | homePage 93 | |> Query.fromHtml 94 | |> Query.findAll [ tag "a" ] 95 | |> Query.first 96 | |> Query.has [ text "Play random questions" ] 97 | 98 | 99 | aLinkToCategoriesIsPresent : Test 100 | aLinkToCategoriesIsPresent = 101 | test "The second link goes to '#categories'" <| 102 | \() -> 103 | homePage 104 | |> Query.fromHtml 105 | |> Query.findAll [ tag "a" ] 106 | |> Query.index 1 107 | |> Query.has [ attribute (href "#categories") ] 108 | 109 | 110 | theCategoriesLinkHasProperText : Test 111 | theCategoriesLinkHasProperText = 112 | test "The second link has text 'Play from a category'" <| 113 | \() -> 114 | homePage 115 | |> Query.fromHtml 116 | |> Query.findAll [ tag "a" ] 117 | |> Query.index 1 118 | |> Query.has [ text "Play from a category" ] 119 | -------------------------------------------------------------------------------- /Step02/README.md: -------------------------------------------------------------------------------- 1 | # Step 2: Results Page! 2 | 3 | ## Goal 4 | 5 | We will now create the results page. This is a really simple one but it will allow us to see some new notions. 6 | 7 | ![Screenshot of the expected result](../Utils/images/step3.png) 8 | 9 | Below you can see the HTML structure that is expected: 10 | 11 | ```html 12 |
13 |

Your score: 3 / 5

14 | Replay 15 |
16 | ``` 17 | 18 | Be careful, the value won't always be `3`, it is given through an argument to your function. 19 | 20 | You can now open in your IDE the file `./ResultPage.elm` and start to code! 21 | 22 | 23 | ## Some hints 24 | 25 | Be careful, this time all the imports needed are not done, you will need to add some functions to the imports lists at the beginning of the file! No surprise, the names are the same than the tags you want to use in HTML! 26 | 27 | ```elm 28 | -- Add your imports below 29 | import Html exposing (Html, div, text) 30 | -- Add the attributes you need below 31 | import Html.Attributes exposing (class) 32 | ``` 33 | 34 | As we've said before, you will need to use the argument `score` given to your function `resultPage`. This is an `Int` and you will need to convert it to a `String` to display it. 35 | 36 | Maybe you can find a helpful function [on this page](https://package.elm-lang.org/packages/elm/core/latest/String). (*There is no need to import the module `Basics`, it's already imported by default in your Elm programs.*) 37 | 38 | 39 | ## Let's start! 40 | [See the result of your code](./ResultPage.elm) (don't forget to refresh to see changes) 41 | 42 | 43 | Once the tests are passing, you can go to the [next step](../Step03). 44 | -------------------------------------------------------------------------------- /Step02/ResultPage.elm: -------------------------------------------------------------------------------- 1 | module Step02.ResultPage exposing (main, resultPage) 2 | 3 | import Html exposing (Html, div, text) 4 | import Html.Attributes exposing (class) 5 | import Utils.Utils exposing (styles, testsIframe) 6 | 7 | 8 | resultPage : Int -> Html msg 9 | resultPage score = 10 | div [] 11 | [ text "Content should go here" 12 | ] 13 | 14 | 15 | 16 | ------------------------------------------------------------------------------------------------------------------------ 17 | -- You don't need to worry about the code below, it only displays the result of your code and the result of the tests -- 18 | ------------------------------------------------------------------------------------------------------------------------ 19 | 20 | 21 | main = 22 | div [] 23 | [ styles 24 | , div [ class "jumbotron" ] [ resultPage 3 ] 25 | , testsIframe 26 | ] 27 | -------------------------------------------------------------------------------- /Step02/Solution/ResultPage.elm: -------------------------------------------------------------------------------- 1 | module Step02.Solution.ResultPage exposing (resultPage) 2 | 3 | import Html exposing (Html, a, div, h1, text) 4 | import Html.Attributes exposing (class, href) 5 | 6 | 7 | resultPage : Int -> Html msg 8 | resultPage score = 9 | div [ class "score" ] 10 | [ h1 [] [ text ("Your score: " ++ String.fromInt score) ] 11 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 12 | ] 13 | -------------------------------------------------------------------------------- /Step02/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step02.Tests.Tests exposing (main) 2 | 3 | import Fuzz exposing (intRange) 4 | import Html exposing (Html, div) 5 | import Html.Attributes exposing (href) 6 | import Random 7 | import Step02.ResultPage exposing (resultPage) 8 | import Test exposing (Test, concat, fuzz, test) 9 | import Test.Html.Query as Query 10 | import Test.Html.Selector exposing (attribute, class, classes, tag, text) 11 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 12 | import Utils.Utils exposing (testStyles) 13 | 14 | 15 | main : Html a 16 | main = 17 | div [] 18 | [ testStyles 19 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 20 | ] 21 | 22 | 23 | suite : Test 24 | suite = 25 | concat 26 | [ divHasProperClassTest 27 | , titleIsPresent 28 | , scoreIsPresent 29 | , replayLinkIsPresent 30 | , replayLinkShouldHaveProperClasses 31 | , replayLinkGoToHome 32 | ] 33 | 34 | 35 | divHasProperClassTest : Test 36 | divHasProperClassTest = 37 | test "The div should have a class 'score'" <| 38 | \() -> 39 | div [] [ resultPage 3 ] 40 | |> Query.fromHtml 41 | |> Query.find [ tag "div" ] 42 | |> Query.has [ class "score" ] 43 | 44 | 45 | titleIsPresent : Test 46 | titleIsPresent = 47 | test "'Your score' is displayed into a h1 tag" <| 48 | \() -> 49 | resultPage 3 50 | |> Query.fromHtml 51 | |> Query.find [ tag "h1" ] 52 | |> Query.has [ text "Your score" ] 53 | 54 | 55 | scoreIsPresent : Test 56 | scoreIsPresent = 57 | fuzz (intRange 0 5) "The proper score is displayed inside the h1 tag" <| 58 | \randomScore -> 59 | resultPage randomScore 60 | |> Query.fromHtml 61 | |> Query.find [ tag "h1" ] 62 | |> Query.has [ text (String.fromInt randomScore) ] 63 | 64 | 65 | replayLinkIsPresent : Test 66 | replayLinkIsPresent = 67 | test "There is a link with the text 'Replay'" <| 68 | \() -> 69 | resultPage 3 70 | |> Query.fromHtml 71 | |> Query.find [ tag "a" ] 72 | |> Query.has [ text "Replay" ] 73 | 74 | 75 | replayLinkShouldHaveProperClasses : Test 76 | replayLinkShouldHaveProperClasses = 77 | test "The replay link should have classes 'btn btn-primary'" <| 78 | \() -> 79 | resultPage 3 80 | |> Query.fromHtml 81 | |> Query.find [ tag "a" ] 82 | |> Query.has [ classes [ "btn", "btn-primary" ] ] 83 | 84 | 85 | replayLinkGoToHome : Test 86 | replayLinkGoToHome = 87 | test "The replay link should go to '#'" <| 88 | \() -> 89 | resultPage 3 90 | |> Query.fromHtml 91 | |> Query.find [ tag "a" ] 92 | |> Query.has [ attribute (href "#") ] 93 | -------------------------------------------------------------------------------- /Step03/README.md: -------------------------------------------------------------------------------- 1 | # Step 3: Let's go further with the results page! 2 | 3 | ## Goal 4 | 5 | This results page is a bit too static to my tastes. It would be nice to display a comment depending on the score! For example: 6 | 7 | ![Screenshot of the page to realize](../Utils/images/step4.png) 8 | 9 | Here is the HTML you'll need to produce: 10 | 11 | ```html 12 |
13 |

Your score: 3 / 5

14 |

Keep going, I'm sure you can do better!

15 | Replay 16 |
17 | ``` 18 | 19 | To do so, open the file [`./ResultPage.elm`](./ResultPage.elm) and add what's missing! We want to choose between these two comments depending on the score: 20 | 21 | - If the score is comprised between 0 and 3 (included), display: `Keep going, I'm sure you can do better!` 22 | - Else, display: `Congrats, this is really good!` 23 | 24 | Be careful to copy/paste the exact same phrases, the tests are pretty strict ;-) 25 | 26 | 27 | ## Let's start! 28 | [See the result of your code](./ResultPage.elm) (don't forget to refresh to see changes) 29 | 30 | 31 | Once the tests are passing, you can go to the [next step](../Step04). -------------------------------------------------------------------------------- /Step03/ResultPage.elm: -------------------------------------------------------------------------------- 1 | module Step03.ResultPage exposing (main, resultPage) 2 | 3 | import Html exposing (Html, a, div, h1, p, text) 4 | import Html.Attributes exposing (class, href) 5 | import Utils.Utils exposing (styles, testsIframe) 6 | 7 | 8 | resultPage : Int -> Html msg 9 | resultPage score = 10 | div [ class "score" ] 11 | [ h1 [] [ text ("Your score: " ++ String.fromInt score ++ " / 5") ] 12 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 13 | ] 14 | 15 | 16 | 17 | ------------------------------------------------------------------------------------------------------------------------ 18 | -- You don't need to worry about the code below, it only displays the result of your code and the result of the tests -- 19 | ------------------------------------------------------------------------------------------------------------------------ 20 | 21 | 22 | main = 23 | div [] 24 | [ styles 25 | , div [ class "jumbotron" ] [ resultPage 3 ] 26 | , testsIframe 27 | ] 28 | -------------------------------------------------------------------------------- /Step03/Solution/ResultPage.elm: -------------------------------------------------------------------------------- 1 | module Step03.Solution.ResultPage exposing (comment, resultPage) 2 | 3 | import Html exposing (Html, a, div, h1, p, text) 4 | import Html.Attributes exposing (class, href) 5 | 6 | 7 | comment : Int -> String 8 | comment score = 9 | if score <= 3 then 10 | "Keep going, I'm sure you can do better!" 11 | 12 | else 13 | "Congrats, this is really good!" 14 | 15 | 16 | resultPage : Int -> Html msg 17 | resultPage score = 18 | div [ class "score" ] 19 | [ h1 [] [ text ("Your score: " ++ String.fromInt score ++ " / 5") ] 20 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 21 | , p [] [ text (comment score) ] 22 | ] 23 | -------------------------------------------------------------------------------- /Step03/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step03.Tests.Tests exposing (main) 2 | 3 | import Fuzz exposing (intRange) 4 | import Html exposing (Html, div) 5 | import Random 6 | import Step03.ResultPage exposing (resultPage) 7 | import Test exposing (Test, concat, fuzz, test) 8 | import Test.Html.Query as Query 9 | import Test.Html.Selector exposing (tag, text) 10 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 11 | import Utils.Utils exposing (testStyles) 12 | 13 | 14 | main : Html a 15 | main = 16 | div [] 17 | [ testStyles 18 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 19 | ] 20 | 21 | 22 | suite : Test 23 | suite = 24 | concat 25 | [ aParagraphShouldNowAppear 26 | , congratsMessageWhenGoodScore 27 | , supportMessageWhenBadScore 28 | ] 29 | 30 | 31 | aParagraphShouldNowAppear : Test 32 | aParagraphShouldNowAppear = 33 | test "There should be a new paragraph in the page" <| 34 | \() -> 35 | resultPage 3 36 | |> Query.fromHtml 37 | |> Query.has [ tag "p" ] 38 | 39 | 40 | congratsMessageWhenGoodScore : Test 41 | congratsMessageWhenGoodScore = 42 | fuzz (intRange 0 3) "With a score between 0 and 3, the paragraph should contain: \"Keep going, I'm sure you can do better!\"" <| 43 | \score -> 44 | resultPage score 45 | |> Query.fromHtml 46 | |> Query.find [ tag "p" ] 47 | |> Query.has [ text "Keep going, I'm sure you can do better!" ] 48 | 49 | 50 | supportMessageWhenBadScore : Test 51 | supportMessageWhenBadScore = 52 | fuzz (intRange 4 5) "With a score between 4 and 5, the paragraph should contain: \"Congrats, this is really good!\"" <| 53 | \score -> 54 | resultPage score 55 | |> Query.fromHtml 56 | |> Query.find [ tag "p" ] 57 | |> Query.has [ text "Congrats, this is really good!" ] 58 | -------------------------------------------------------------------------------- /Step04/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step04.CategoriesPage exposing (categories, main) 2 | 3 | import Html exposing (Html, a, span, strong, text) 4 | import Html.Attributes exposing (href) 5 | 6 | 7 | categories : List Category 8 | categories = 9 | [ { id = 9, name = "General Knowledge" } 10 | , { id = 10, name = "Entertainment: Books" } 11 | , { id = 11, name = "Entertainment: Film" } 12 | , { id = 12, name = "Entertainment: Music" } 13 | , { id = 13, name = "Entertainment: Musicals & Theatres" } 14 | , { id = 14, name = "Entertainment: Television" } 15 | , { id = 15, name = "Entertainment: Video Games" } 16 | , { id = 16, name = "Entertainment: Board Games" } 17 | , { id = 17, name = "Science & Nature" } 18 | , { id = 18, name = "Science: Computers" } 19 | , { id = 19, name = "Science: Mathematics" } 20 | , { id = 20, name = "Mythology" } 21 | , { id = 21, name = "Sports" } 22 | , { id = 22, name = "Geography" } 23 | , { id = 23, name = "History" } 24 | , { id = 24, name = "Politics" } 25 | , { id = 25, name = "Art" } 26 | , { id = 26, name = "Celebrities" } 27 | , { id = 27, name = "Animals" } 28 | , { id = 28, name = "Vehicles" } 29 | , { id = 29, name = "Entertainment: Comics" } 30 | , { id = 30, name = "Science: Gadgets" } 31 | , { id = 31, name = "Entertainment: Japanese Anime & Manga" } 32 | , { id = 32, name = "Entertainment: Cartoon & Animations" } 33 | ] 34 | 35 | 36 | main : Html msg 37 | main = 38 | span [] 39 | [ text "Congrats it works! " 40 | , strong [] [ a [ href "../Step05/" ] [ text "Go to next step -->" ] ] 41 | ] 42 | -------------------------------------------------------------------------------- /Step04/README.md: -------------------------------------------------------------------------------- 1 | # Step4: Categories List 2 | 3 | ## Goal 4 | 5 | This new page should list the available categories of questions. It will be done with several steps as there are many new concepts to apprehend. 6 | 7 | This time, by going to `CategoriesPage.elm`, you will see that the page has a compilation error! Indeed, the list of categories is declared in the code in the following manner: 8 | 9 | ```elm 10 | categories : List Category 11 | categories = 12 | [ 13 | -- list of categories 14 | ] 15 | ``` 16 | 17 | By looking at the type annotation, we can see that it's a list of elements, which are of type `Category`... but this type doesn't exist! This will be your mission for this step: make the code compile by creating the `Category` type. 18 | 19 | It should contain two fields: 20 | 21 | - `id` of type `Int` 22 | - `name` of type `String` 23 | 24 | 25 | ## Wait... How can we create a type? 26 | 27 | If you don't remember how to create a new type, here are two links to the documentation. 28 | Be careful, there are two ways to declare a type which are not equivalent, choose wisely! 29 | 30 | - [Custom Types](https://guide.elm-lang.org/types/custom_types.html) 31 | - [Type Aliases](https://guide.elm-lang.org/types/type_aliases.html) 32 | 33 | 34 | ## Let's start! 35 | 36 | [See the result of your code](./CategoriesPage.elm) (don't forget to refresh to see changes) 37 | 38 | Once the page compiles, you can go to the [next step](../Step05). 39 | -------------------------------------------------------------------------------- /Step04/Solution/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step04.Solution.CategoriesPage exposing (Category, categories, main) 2 | 3 | import Html exposing (Html, text) 4 | 5 | 6 | type alias Category = 7 | { id : Int 8 | , name : String 9 | } 10 | 11 | 12 | categories : List Category 13 | categories = 14 | [ { id = 9, name = "General Knowledge" } 15 | , { id = 10, name = "Entertainment: Books" } 16 | , { id = 11, name = "Entertainment: Film" } 17 | , { id = 12, name = "Entertainment: Music" } 18 | , { id = 13, name = "Entertainment: Musicals & Theatres" } 19 | , { id = 14, name = "Entertainment: Television" } 20 | , { id = 15, name = "Entertainment: Video Games" } 21 | , { id = 16, name = "Entertainment: Board Games" } 22 | , { id = 17, name = "Science & Nature" } 23 | , { id = 18, name = "Science: Computers" } 24 | , { id = 19, name = "Science: Mathematics" } 25 | , { id = 20, name = "Mythology" } 26 | , { id = 21, name = "Sports" } 27 | , { id = 22, name = "Geography" } 28 | , { id = 23, name = "History" } 29 | , { id = 24, name = "Politics" } 30 | , { id = 25, name = "Art" } 31 | , { id = 26, name = "Celebrities" } 32 | , { id = 27, name = "Animals" } 33 | , { id = 28, name = "Vehicles" } 34 | , { id = 29, name = "Entertainment: Comics" } 35 | , { id = 30, name = "Science: Gadgets" } 36 | , { id = 31, name = "Entertainment: Japanese Anime & Manga" } 37 | , { id = 32, name = "Entertainment: Cartoon & Animations" } 38 | ] 39 | 40 | 41 | main : Html msg 42 | main = 43 | text "Congrats it works! Go to next step!" 44 | -------------------------------------------------------------------------------- /Step05/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step05.CategoriesPage exposing (Category, categories, categoriesPage, main) 2 | 3 | import Html exposing (Html, a, div, h1, li, text, ul) 4 | import Html.Attributes exposing (class, href, style) 5 | import Utils.Utils exposing (styles, testsIframe) 6 | 7 | 8 | type alias Category = 9 | { id : Int 10 | , name : String 11 | } 12 | 13 | 14 | categoriesPage : Html msg 15 | categoriesPage = 16 | div [] 17 | [ text "Content of the page" ] 18 | 19 | 20 | categories : List Category 21 | categories = 22 | [ { id = 9, name = "General Knowledge" } 23 | , { id = 10, name = "Entertainment: Books" } 24 | , { id = 11, name = "Entertainment: Film" } 25 | , { id = 12, name = "Entertainment: Music" } 26 | , { id = 13, name = "Entertainment: Musicals & Theatres" } 27 | , { id = 14, name = "Entertainment: Television" } 28 | , { id = 15, name = "Entertainment: Video Games" } 29 | , { id = 16, name = "Entertainment: Board Games" } 30 | , { id = 17, name = "Science & Nature" } 31 | , { id = 18, name = "Science: Computers" } 32 | , { id = 19, name = "Science: Mathematics" } 33 | , { id = 20, name = "Mythology" } 34 | , { id = 21, name = "Sports" } 35 | , { id = 22, name = "Geography" } 36 | , { id = 23, name = "History" } 37 | , { id = 24, name = "Politics" } 38 | , { id = 25, name = "Art" } 39 | , { id = 26, name = "Celebrities" } 40 | , { id = 27, name = "Animals" } 41 | , { id = 28, name = "Vehicles" } 42 | , { id = 29, name = "Entertainment: Comics" } 43 | , { id = 30, name = "Science: Gadgets" } 44 | , { id = 31, name = "Entertainment: Japanese Anime & Manga" } 45 | , { id = 32, name = "Entertainment: Cartoon & Animations" } 46 | ] 47 | 48 | 49 | 50 | ------------------------------------------------------------------------------------------------------------------------ 51 | -- You don't need to worry about the code below, it only displays the result of your code and the result of the tests -- 52 | ------------------------------------------------------------------------------------------------------------------------ 53 | 54 | 55 | main = 56 | div [] 57 | [ styles 58 | , div [ class "jumbotron" ] [ categoriesPage ] 59 | , testsIframe 60 | ] 61 | -------------------------------------------------------------------------------- /Step05/README.md: -------------------------------------------------------------------------------- 1 | # Step5: Categories list (part 2) 2 | 3 | ## Goal 4 | 5 | Now that we've created our `Category` type, let's display this list of categories! 6 | 7 | ![Screenshot of the categories page](../Utils/images/step6.png) 8 | 9 | The expected HTML structure is the following one: 10 | 11 | ```html 12 |
13 |

Play within a given category

14 | 27 |
28 | ``` 29 | 30 | We've already displayed HTML a few times, but here let's consider that we don't know the number of categories, which is why we cannot display them one by one with 24 lines of code. 31 | 32 | Your spider/developer sense must be tingling! *We need a loop!* But wait... Elm don't have loops! 😱 33 | 34 | However, it is a functional programming language so we have access to some operations we can use on lists to transform them. 35 | 36 | When you think about it, it's exactly what we cant to do: transform our list of `Category` into a list of HTML elements: a `li` that represents a category. 37 | 38 | Once this list is transformed, we can directly use it as the second argument of a `ul` function to have the expected result (remember that the second argument of a HTML function is a list of HTML elements!) 39 | 40 | The function that will help use transform the list is `List.map` and you can [find its documentation there](http://package.elm-lang.org/packages/elm-lang/core/latest/List#map). As you can see, `List.map` takes two arguments: the first one is a function that will transform a single element of the list, and the second argument is the list itself: 41 | 42 | ```elm 43 | numbers : Int 44 | numbers = 45 | [1, 2, 3, 4, 5] 46 | 47 | toSquare : Int -> Int 48 | toSquare number = 49 | number * number 50 | 51 | -- Contains [1, 4, 9, 16, 25] 52 | squaredNumbers : Int 53 | squaredNumbers = 54 | List.map toSquare numbers 55 | ``` 56 | 57 | 58 | ## Let's start! 59 | 60 | [See the result of your code](./CategoriesPage.elm) (don't forget to refresh to see changes) 61 | 62 | Once the tests are passing, you can go to the [next step](../Step06). -------------------------------------------------------------------------------- /Step05/Solution/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step05.Solution.CategoriesPage exposing (Category, categories, categoriesPage, categoriesView, categoryView) 2 | 3 | import Html exposing (Html, a, div, h1, iframe, li, text, ul) 4 | import Html.Attributes exposing (class, href, src, style) 5 | 6 | 7 | type alias Category = 8 | { id : Int 9 | , name : String 10 | } 11 | 12 | 13 | categoriesPage : Html msg 14 | categoriesPage = 15 | div [] 16 | [ h1 [] 17 | [ text "Play within a given category" ] 18 | , ul [ class "categories" ] categoriesView 19 | ] 20 | 21 | 22 | categoriesView : List (Html msg) 23 | categoriesView = 24 | List.map categoryView categories 25 | 26 | 27 | categoryView : Category -> Html msg 28 | categoryView category = 29 | let 30 | link = 31 | "#game/category/" ++ String.fromInt category.id 32 | in 33 | li [] 34 | [ a [ href link, class "btn btn-primary" ] [ text category.name ] 35 | ] 36 | 37 | 38 | categories : List Category 39 | categories = 40 | [ { id = 9, name = "General Knowledge" } 41 | , { id = 10, name = "Entertainment: Books" } 42 | , { id = 11, name = "Entertainment: Film" } 43 | , { id = 12, name = "Entertainment: Music" } 44 | , { id = 13, name = "Entertainment: Musicals & Theatres" } 45 | , { id = 14, name = "Entertainment: Television" } 46 | , { id = 15, name = "Entertainment: Video Games" } 47 | , { id = 16, name = "Entertainment: Board Games" } 48 | , { id = 17, name = "Science & Nature" } 49 | , { id = 18, name = "Science: Computers" } 50 | , { id = 19, name = "Science: Mathematics" } 51 | , { id = 20, name = "Mythology" } 52 | , { id = 21, name = "Sports" } 53 | , { id = 22, name = "Geography" } 54 | , { id = 23, name = "History" } 55 | , { id = 24, name = "Politics" } 56 | , { id = 25, name = "Art" } 57 | , { id = 26, name = "Celebrities" } 58 | , { id = 27, name = "Animals" } 59 | , { id = 28, name = "Vehicles" } 60 | , { id = 29, name = "Entertainment: Comics" } 61 | , { id = 30, name = "Science: Gadgets" } 62 | , { id = 31, name = "Entertainment: Japanese Anime & Manga" } 63 | , { id = 32, name = "Entertainment: Cartoon & Animations" } 64 | ] 65 | -------------------------------------------------------------------------------- /Step05/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step05.Tests.Tests exposing (eachCategoryHasItsNameDisplayed, everyCategoriesAreDisplayed, getCategory, listOfCategoriesIsPresent, replayLinkShouldHaveProperClasses, replayLinkShouldHaveProperLink, suite, titleIsPresentWithProperText) 2 | 3 | import Expect exposing (fail) 4 | import Fuzz exposing (intRange) 5 | import Html exposing (Html, div) 6 | import Html.Attributes 7 | import Random 8 | import Step05.CategoriesPage exposing (Category, categories, categoriesPage) 9 | import Test exposing (Test, concat, fuzz, test) 10 | import Test.Html.Query as Query 11 | import Test.Html.Selector exposing (attribute, class, classes, tag, text) 12 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 13 | import Utils.Utils exposing (testStyles) 14 | 15 | 16 | main : Html a 17 | main = 18 | div [] 19 | [ testStyles 20 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 21 | ] 22 | 23 | 24 | suite : Test 25 | suite = 26 | concat 27 | [ titleIsPresentWithProperText 28 | , listOfCategoriesIsPresent 29 | , everyCategoriesAreDisplayed 30 | , eachCategoryHasItsNameDisplayed 31 | , replayLinkShouldHaveProperClasses 32 | , replayLinkShouldHaveProperLink 33 | ] 34 | 35 | 36 | titleIsPresentWithProperText : Test 37 | titleIsPresentWithProperText = 38 | test "There should be a title with the proper text \"Play within a given category\"" <| 39 | \() -> 40 | categoriesPage 41 | |> Query.fromHtml 42 | |> Query.find [ tag "h1" ] 43 | |> Query.has [ text "Play within a given category" ] 44 | 45 | 46 | listOfCategoriesIsPresent : Test 47 | listOfCategoriesIsPresent = 48 | test "There should be an 'ul' tag with the class \"categories\"" <| 49 | \() -> 50 | categoriesPage 51 | |> Query.fromHtml 52 | |> Query.find [ tag "ul" ] 53 | |> Query.has [ class "categories" ] 54 | 55 | 56 | everyCategoriesAreDisplayed : Test 57 | everyCategoriesAreDisplayed = 58 | test "There are 24 'li' tags displayed, one for each category" <| 59 | \() -> 60 | categoriesPage 61 | |> Query.fromHtml 62 | |> Query.findAll [ tag "li" ] 63 | |> Query.count (Expect.equal 24) 64 | 65 | 66 | eachCategoryHasItsNameDisplayed : Test 67 | eachCategoryHasItsNameDisplayed = 68 | fuzz (intRange 0 23) "Each category has its name displayed" <| 69 | \categoryIndex -> 70 | case getCategory categoryIndex of 71 | Just category -> 72 | categoriesPage 73 | |> Query.fromHtml 74 | |> Query.has [ text category.name ] 75 | 76 | Nothing -> 77 | "Cannot find category with index " 78 | ++ String.fromInt categoryIndex 79 | ++ ", have you touched the categories list?" 80 | |> fail 81 | 82 | 83 | replayLinkShouldHaveProperClasses : Test 84 | replayLinkShouldHaveProperClasses = 85 | test "Each link has the classes \"btn btn-primary\"" <| 86 | \() -> 87 | categoriesPage 88 | |> Query.fromHtml 89 | |> Query.findAll [ tag "a" ] 90 | |> Query.each (Query.has [ classes [ "btn", "btn-primary" ] ]) 91 | 92 | 93 | replayLinkShouldHaveProperLink : Test 94 | replayLinkShouldHaveProperLink = 95 | fuzz (intRange 0 23) "Each category have the proper link" <| 96 | \categoryIndex -> 97 | let 98 | linkMaybe = 99 | getCategory categoryIndex 100 | |> Maybe.map 101 | (.id 102 | >> String.fromInt 103 | >> (++) "#game/category/" 104 | ) 105 | in 106 | case linkMaybe of 107 | Just link -> 108 | categoriesPage 109 | |> Query.fromHtml 110 | |> Query.has [ tag "a", attribute (Html.Attributes.href link) ] 111 | 112 | Nothing -> 113 | "Cannot find category with index " 114 | ++ String.fromInt categoryIndex 115 | ++ ", have you touched the categories list?" 116 | |> fail 117 | 118 | 119 | getCategory : Int -> Maybe Category 120 | getCategory index = 121 | categories 122 | |> List.drop index 123 | |> List.head 124 | -------------------------------------------------------------------------------- /Step06/README.md: -------------------------------------------------------------------------------- 1 | # Step 6: The Elm Architecture 2 | 3 | ## Goal 4 | 5 | Let's take a break from the categories page and talk about *The Elm Architecture (TEA)*. Be prepared, there is a lot to read for this step, but it's for your own good! 😄 6 | 7 | After having developed many web apps, the Elm developers have found a really simple and efficient pattern to build their applications and it was standardized under the name `The Elm Architecture`. 8 | 9 | A web app in Elm is constituted of three elements: 10 | 11 | - a `model`, containing the application state 12 | - a `view` function that is responsible for rendering the HTML from the `model` ; this HTML can emit _messages_ to update the state 13 | - a `update` function, that will receive emitted _messages_ and the current `model` and is responsible for updating the model 14 | 15 | The only place where you can change your state is the `update` function. The only place where you can display something is from the `view` function. Every value of your state should live in the global `model` 16 | 17 | The workflow of an Elm application is the following: 18 | 19 | - We create an application by providing the initial model, a `view` and a `update` function 20 | - The `view` function is called with the `model` as only argument and produces the HTML view 21 | - An event is triggered that generates a _message_ (user click, timer, HTTP request finished, ...) 22 | - This _message_ is given to the `update` function along with the current `state`, the function returns the new updated `model` 23 | - The `view` function generates the new HTML view accoring to this new `model` 24 | 25 | ![The Elm Architecture](../Utils/images/step7-tea.png) 26 | 27 | ## The Counter 28 | 29 | Let's see how this works with a simple counter. [You can find the working example online.](https://ellie-app.com/37gVmD7Tm9Ma1) Here is the commented code for you to analyze each element: 30 | 31 | ```elm 32 | module Counter exposing (..) 33 | 34 | import Browser 35 | import Html exposing (Html, button, span, text, div) 36 | import Html.Events exposing (onClick) 37 | 38 | 39 | -- Here we declare app through the function `Browser.sandbox` 40 | -- with an initial model (the Int `O`), a `view` function and 41 | -- an `update` fonction 42 | main = 43 | Browser.sandbox { init = 0, view = view, update = update } 44 | 45 | -- Here we are creating a union type representing all the messages 46 | -- our application can receive 47 | type Msg 48 | = Increment 49 | | Decrement 50 | 51 | 52 | -- The update function take a message and a model as arguments 53 | -- to return the new model 54 | update msg model = 55 | case msg of 56 | Increment -> 57 | model + 1 58 | 59 | Decrement -> 60 | model - 1 61 | 62 | 63 | -- The view function just create the proper HTML according 64 | -- to the model 65 | view model = 66 | div [] 67 | [ button [ onClick Decrement ] [ text "-" ] 68 | , span [] [ text (toString model) ] 69 | -- Thanks to `onClick` we can generate a message when 70 | -- the user clicks on this button 71 | , button [ onClick Increment ] [ text "+" ] 72 | ] 73 | ``` 74 | 75 | 76 | ## Let's practice! 77 | 78 | Before setting up this architecture on the categories page, let's try it with a slightly easier example. We will build a page asking if the user is underage or not, and then display a message accordingly. 79 | 80 | ![Page to realize](../Utils/images/step7-tea.png) 81 | 82 | A working example [can be found there](https://underage-or-adult.surge.sh/). 83 | 84 | 85 | ### Model 86 | 87 | Our model will only contain a single value, which is the only data we need: the user choice. 88 | 89 | However, at loading, the user haven't done any choice, so this value can be undefined. We could make the choice to have a default value, but the goal is to only display the message once the user has made a choice. 90 | 91 | In JavaScript, we could solve this problem with the use of a `null` or `undefined` value... And by making possible these kinds of errors: 92 | 93 | ```js 94 | const user = null; 95 | user.name; 96 | // VM245:1 Uncaught TypeError: Cannot read property 'name' of null 97 | 98 | let myFunction = undefined; 99 | myFunction(); 100 | // Uncaught TypeError: myFunction is not a function 101 | ``` 102 | 103 | It's really difficult with JavaScript to not have these errors in production, because of the dynamic nature of the language. Elm has decided to avoid having `null` or `undefined`. 104 | 105 | Then how do we represent an empty value? We can do that by using a [custom type](https://guide.elm-lang.org/types/custom_types.html) `UserStatus` that can have three values: `NotSpecified`, `UnderAge` and `Adult`. The advantage here is that the compiler will force you to handle every possible cases! 106 | 107 | With the help of a `case...of` (we've already seen it within the counter example) we can then display a message according to the value. A little example below on another use case: 108 | 109 | ```elm 110 | type ShirtSize = Large | Medium | Small 111 | 112 | displayShirtSize : ShirtSize -> Html Msg 113 | displayShirtSize size = 114 | case size of 115 | Large -> 116 | text "Large size" 117 | Medium -> 118 | text "Medium size" 119 | Small -> 120 | text "Small size" 121 | ``` 122 | 123 | 124 | ### Messages 125 | 126 | A [union type](https://guide.elm-lang.org/types/union_types.html) `Msg` is defined and already contains the only message we need: `UserStatusSelected`, that takes a `UserStatus` as argument, which is another union type that is defined in the code. 127 | 128 | 129 | ## Update and View 130 | 131 | These two functions are already created, but they will need a few changes to get it working. For example, the change function does not currently update the state, because it always return the same value! Change this to make the tests pass! 132 | 133 | 134 | ## Let's start! 135 | 136 | [See the result of your code](./UserStatus.elm) (don't forget to refresh to see changes) 137 | 138 | Once the tests are passing, you can go to the [next step](../Step07). 139 | -------------------------------------------------------------------------------- /Step06/Solution/UserStatus.elm: -------------------------------------------------------------------------------- 1 | module Step06.Solution.UserStatus exposing (initialModel, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, div, input, label, p, text) 5 | import Html.Attributes exposing (class, for, id, name, type_) 6 | import Html.Events exposing (onClick) 7 | 8 | 9 | main = 10 | Browser.sandbox { init = initialModel, update = update, view = view } 11 | 12 | 13 | type UserStatus 14 | = Underage 15 | | Adult 16 | | Unknown 17 | 18 | 19 | type Msg 20 | = UserStatusSelected UserStatus 21 | 22 | 23 | initialModel : UserStatus 24 | initialModel = 25 | Unknown 26 | 27 | 28 | view : UserStatus -> Html Msg 29 | view userStatus = 30 | div [] 31 | [ div [ class "jumbotron" ] 32 | [ userStatusForm 33 | , p [] [ statusMessage userStatus ] 34 | ] 35 | ] 36 | 37 | 38 | userStatusForm : Html Msg 39 | userStatusForm = 40 | div [ class "mb-3" ] 41 | [ input 42 | [ id "underage" 43 | , name "status" 44 | , type_ "radio" 45 | , onClick (UserStatusSelected Underage) 46 | ] 47 | [ text "I'm underage" ] 48 | , label [ class "mr-3", for "underage" ] [ text "I'm underage" ] 49 | , input 50 | [ id "adult" 51 | , name "status" 52 | , type_ "radio" 53 | , onClick (UserStatusSelected Adult) 54 | ] 55 | [ text "I'm an adult!" ] 56 | , label [ for "adult" ] [ text "I'm an adult" ] 57 | ] 58 | 59 | 60 | statusMessage : UserStatus -> Html Msg 61 | statusMessage userStatus = 62 | case userStatus of 63 | Underage -> 64 | text "You are underage" 65 | 66 | Adult -> 67 | text "You are an adult" 68 | 69 | Unknown -> 70 | text "" 71 | 72 | 73 | update : Msg -> UserStatus -> UserStatus 74 | update message userStatus = 75 | case message of 76 | UserStatusSelected newUserStatus -> 77 | -- We've changed this to return the new user status 78 | newUserStatus 79 | -------------------------------------------------------------------------------- /Step06/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step06.Tests.Tests exposing (main) 2 | 3 | import Expect 4 | import Html exposing (Html, div) 5 | import Html.Attributes exposing (type_) 6 | import Random 7 | import Step06.UserStatus exposing (initialModel, update, view) 8 | import Test exposing (Test, concat, test) 9 | import Test.Html.Event as Event 10 | import Test.Html.Query as Query 11 | import Test.Html.Selector exposing (attribute, text) 12 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 13 | import Utils.Utils exposing (testStyles) 14 | 15 | 16 | main : Html a 17 | main = 18 | div [] 19 | [ testStyles 20 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 21 | ] 22 | 23 | 24 | suite : Test 25 | suite = 26 | concat 27 | [ atFirstThereShouldBeNoMessage 28 | , whenFirstRadioButtonIsClickedUserShouldBeUnderage 29 | , whenSecondRadioButtonIsClickedUserShouldBeAdult 30 | ] 31 | 32 | 33 | atFirstThereShouldBeNoMessage : Test 34 | atFirstThereShouldBeNoMessage = 35 | test "At first there should be no message displayed" <| 36 | \() -> 37 | Expect.all 38 | [ Query.hasNot [ text "You are underage" ] 39 | , Query.hasNot [ text "You are an adult" ] 40 | ] 41 | (view initialModel |> Query.fromHtml) 42 | 43 | 44 | whenFirstRadioButtonIsClickedUserShouldBeUnderage : Test 45 | whenFirstRadioButtonIsClickedUserShouldBeUnderage = 46 | test "When we click on the first radio button, a message \"You are underage\" should appear" <| 47 | \() -> 48 | let 49 | messageTriggered = 50 | view initialModel 51 | |> Query.fromHtml 52 | |> Query.findAll [ attribute (type_ "radio") ] 53 | |> Query.first 54 | |> Event.simulate Event.click 55 | |> Event.toResult 56 | 57 | updatedModel = 58 | messageTriggered 59 | |> Result.map (\msg -> update msg initialModel) 60 | 61 | updatedView = 62 | updatedModel 63 | |> Result.map (\model -> view model) 64 | |> Result.map Query.fromHtml 65 | in 66 | Expect.all 67 | [ \result -> 68 | result 69 | |> Result.map (Query.has [ text "You are underage" ]) 70 | |> Result.withDefault (Expect.fail "\"You are underage\" should be present") 71 | , \result -> 72 | Result.map (Query.hasNot [ text "You are an adult" ]) result 73 | |> Result.withDefault (Expect.fail "\"You are an adult\" should not be present") 74 | ] 75 | updatedView 76 | 77 | 78 | whenSecondRadioButtonIsClickedUserShouldBeAdult : Test 79 | whenSecondRadioButtonIsClickedUserShouldBeAdult = 80 | test "When we click on the second radio button, a message \"You are an adult\" should appear" <| 81 | \() -> 82 | let 83 | messageTriggered = 84 | view initialModel 85 | |> Query.fromHtml 86 | |> Query.findAll [ attribute (type_ "radio") ] 87 | |> Query.index 1 88 | |> Event.simulate Event.click 89 | |> Event.toResult 90 | 91 | updatedModel = 92 | messageTriggered 93 | |> Result.map (\msg -> update msg initialModel) 94 | 95 | updatedView = 96 | updatedModel 97 | |> Result.map (\model -> view model) 98 | |> Result.map Query.fromHtml 99 | in 100 | Expect.all 101 | [ \result -> 102 | Result.map (Query.hasNot [ text "You are underage" ]) result 103 | |> Result.withDefault (Expect.fail "\"You are underage\" should not be present") 104 | , \result -> 105 | Result.map (Query.has [ text "You are an adult" ]) result 106 | |> Result.withDefault (Expect.fail "\"You are an adult\" should be present") 107 | ] 108 | updatedView 109 | -------------------------------------------------------------------------------- /Step06/UserStatus.elm: -------------------------------------------------------------------------------- 1 | module Step06.UserStatus exposing (initialModel, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, div, input, label, p, text) 5 | import Html.Attributes exposing (class, for, id, name, type_) 6 | import Html.Events exposing (onClick) 7 | import Utils.Utils exposing (styles, testsIframe) 8 | 9 | 10 | {-| This line creates a program with everything it needs (see the README) 11 | You don't need to modify it. 12 | -} 13 | main = 14 | Browser.sandbox { init = initialModel, view = view, update = update } 15 | 16 | 17 | {-| Modify this union type to fit our needs. 18 | It should contain three values: NotSpecified, UnderAge and Adult. 19 | -} 20 | type UserStatus 21 | = ModifyThisType 22 | 23 | 24 | {-| Don't modify this union type, it already contains the only message we need. 25 | -} 26 | type Msg 27 | = UserStatusSelected UserStatus 28 | 29 | 30 | {-| Once the type UserStatus is fixed, you'll be able to initialize properly the model here. 31 | -} 32 | initialModel : UserStatus 33 | initialModel = 34 | ModifyThisType 35 | 36 | 37 | {-| Don't modify this function, it displays everything you need and also displays the tests. 38 | -} 39 | view : UserStatus -> Html Msg 40 | view userStatus = 41 | div [] 42 | [ div [ class "jumbotron" ] 43 | [ userStatusForm 44 | , p [] [ statusMessage userStatus ] 45 | ] 46 | , displayTestsAndStyle 47 | ] 48 | 49 | 50 | {-| You will need to modify the messages sent by those inputs. 51 | You can see how `onClick` is used to send a message `UserStatusSelected` with a value of `ModifyThisType`. 52 | Once you have changed the UserStatus type, you can change the messages here 53 | -} 54 | userStatusForm : Html Msg 55 | userStatusForm = 56 | div [ class "mb-3" ] 57 | [ input 58 | [ id "underage" 59 | , name "status" 60 | , type_ "radio" 61 | , onClick (UserStatusSelected ModifyThisType) 62 | ] 63 | [ text "I'm underage" ] 64 | , label [ class "mr-3", for "underage" ] [ text "I'm underage" ] 65 | , input 66 | [ id "adult" 67 | , name "status" 68 | , type_ "radio" 69 | , onClick (UserStatusSelected ModifyThisType) 70 | ] 71 | [ text "I'm an adult!" ] 72 | , label [ for "adult" ] [ text "I'm an adult" ] 73 | ] 74 | 75 | 76 | {-| Customize this message according to the user status. If the status is not specified, just return an empty text. 77 | -} 78 | statusMessage : UserStatus -> Html Msg 79 | statusMessage userStatus = 80 | case userStatus of 81 | ModifyThisType -> 82 | text "Personalize the message according to the user status" 83 | 84 | 85 | {-| Update the model according to the message received 86 | -} 87 | update : Msg -> UserStatus -> UserStatus 88 | update message userStatus = 89 | case message of 90 | UserStatusSelected newUserStatus -> 91 | userStatus 92 | 93 | 94 | 95 | ----------------------------------------------------------------------------------------- 96 | -- You don't need to worry about the code below, this function only displays the tests -- 97 | ----------------------------------------------------------------------------------------- 98 | 99 | 100 | displayTestsAndStyle : Html Msg 101 | displayTestsAndStyle = 102 | div [] [ styles, testsIframe ] 103 | -------------------------------------------------------------------------------- /Step07/README.md: -------------------------------------------------------------------------------- 1 | # Step 7: The Elm Architecture 2 | 3 | ## Goal 4 | 5 | We've seen how The Elm Architecture works on the previous step. As we've seen, we need to declare our web application in the following way: 6 | 7 | ```elm 8 | main = 9 | Browser.sandbox { init = initialModel, view = view, update = update } 10 | ``` 11 | 12 | This function `sandbox` is in fact the most basic kind of web application you can do. There are other functions which provides more capabilities to your application: 13 | 14 | 15 | - `Browser.element` allows you to have _side effects_ like random numbers and to interact with the outside world (for example JavaScript code or HTTP requests). This is usually used to embed Elm parts inside an existing JavaScript application 16 | - `Browser.document` allows you the same plus controlling the title of the page and the `body` entirely. 17 | - `Browser.application` allows you the same things than `Browser.document` and allows you to manages changes to the URL. 18 | 19 | The part about _side effects_ needs more explanations. Indeed, in Elm, every functions are pure. That means that their return value only depends on its parameters and that they can't alter anything outside their scope. 20 | 21 | That make them incompatible with the notion of random for example : a function which returns a random value cannot be pure as two identical calls would return different values! The same happens when we want to access data in the `localstorage` or get data from a web server with a HTTP request: we can't guarantee that each call of the function will produce the same value, so this cannot be done with pure functions! 22 | 23 | These are important things for a web app to do, so how do Elm handle side effects? That's where we will talk about commands (`Cmd`) in Elm! A command is a way for our Elm code to ask the Elm runtime to perform an effect. For example, we can use them to ask the Elm runtime: "Okay, I need a random `Int` between 0 and 10! Can you generate one and send me the value inside a `ValueCreated` message?". 24 | 25 | And this is the trick: our function will return the same command each time we'll call it with the same arguments. Then Elm will execute this order, then call our update function with the value encapsulated inside the `ValueCreated` message (which is a message we've created ourselves that contains an `Int`) 26 | 27 | In the code everything stays pure, the side effects happens outside of our application! 28 | 29 | 30 | ## An example with a random number 31 | 32 | Let's open the *[./RandomNumber.elm](./RandomNumber.elm)* file, in your browser and in your IDE. The code is annotated with numbers for a better comprehension. 33 | 34 | We can see near `(1)` that we're declaring a `Browser.element` program that needs a fourth argument, a function that return the *Subscription*s `(2)`. *Subscriptions* are a mechanism allowing us to receive messages from JavaScript. As we don't need this, our function just returns `Sub.none`, meaning we don't want to _subscribe_ to JavaScript. 35 | 36 | Then, near `(3)` we can see something different about the signature of the functions `init` and `update`: instead of only returning a `Model`, they return a tuple of two values: `(Model, Cmd Msg)`. By returning a `Cmd`, we will be able to ask the Elm runtime to execute side effects. Notice that this command is parameterized by `Msg`, meaning that once a command is done, the Elm runtime will need one of our application `Msg` to give us back the result. 37 | 38 | In the initial model, we don't need to perform any side effect, so we are returning `Cmd.none`. 39 | 40 | The `view` function has nothing special except near `(4)`: we ask to send a message `GenerateNumber` that will be sent to our update function. Then, the update function is able to ask the Elm runtime to produce a random number near (5). As you can see, we're not updating the model here, we're just asking something to the Elm runtime: 41 | 42 | ```elm 43 | Random.generate OnNumberGenerated (Random.int 0 10) 44 | ``` 45 | 46 | `Random.generate` creates a command to generate a random value. The second argument `Random.int 0 10` specify that we want an `Int` between 0 and 10. The first argument `OnNumberGenerated` is the message used by the Elm runtime to return the result to our `update` function. 47 | 48 | That's exactly what happens near `(6)`, we receive the generated value and store it inside our model. There is no need for another side effect, so we can also return `Cmd.none`. 49 | 50 | 51 | ## Let's start! 52 | 53 | Well, in fact... No! This step was only an example, but be prepared, on the next step we're going to implement the Elm Architecture on our categories page! 54 | 55 | 56 | You can now go to the [next step](../Step08). 57 | -------------------------------------------------------------------------------- /Step07/RandomNumber.elm: -------------------------------------------------------------------------------- 1 | module Step07.RandomNumber exposing (Model, Msg(..), init, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, button, div, text) 5 | import Html.Events exposing (onClick) 6 | import Random 7 | import Utils.Utils exposing (styles) 8 | 9 | 10 | {-| (1) 11 | -} 12 | main : Program () Model Msg 13 | main = 14 | Browser.element 15 | { init = \() -> init 16 | , update = update 17 | , view = view 18 | , subscriptions = \model -> Sub.none -- (2) 19 | } 20 | 21 | 22 | type alias Model = 23 | { randomNumber : Int 24 | } 25 | 26 | 27 | type Msg 28 | = GenerateNumber 29 | | OnNumberGenerated Int 30 | 31 | 32 | {-| (3) 33 | -} 34 | init : ( Model, Cmd Msg ) 35 | init = 36 | ( Model 0, Cmd.none ) 37 | 38 | 39 | {-| (3) 40 | -} 41 | update : Msg -> Model -> ( Model, Cmd Msg ) 42 | update msg model = 43 | case msg of 44 | GenerateNumber -> 45 | -- (5) 46 | ( model, Random.generate OnNumberGenerated (Random.int 0 10) ) 47 | 48 | OnNumberGenerated number -> 49 | -- (6) 50 | ( Model number, Cmd.none ) 51 | 52 | 53 | view : Model -> Html Msg 54 | view model = 55 | div [] 56 | -- (4) 57 | [ button [ onClick GenerateNumber ] [ text "Generate random number" ] 58 | , div [] 59 | [ text ("The random number : " ++ String.fromInt model.randomNumber) ] 60 | , styles 61 | ] 62 | -------------------------------------------------------------------------------- /Step08/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step08.CategoriesPage exposing (Model, Msg(..), RemoteData(..), init, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, div, h1, text) 5 | import Html.Attributes exposing (class) 6 | import Http exposing (expectString) 7 | import Result exposing (Result) 8 | import Utils.Utils exposing (styles, testsIframe) 9 | 10 | 11 | main : Program () Model Msg 12 | main = 13 | Browser.element 14 | { init = \_ -> init 15 | , update = update 16 | , view = displayTestsAndView 17 | , subscriptions = \model -> Sub.none 18 | } 19 | 20 | 21 | type alias Model = 22 | { categories : RemoteData String 23 | } 24 | 25 | 26 | type Msg 27 | = OnCategoriesFetched (Result Http.Error String) 28 | 29 | 30 | type alias Category = 31 | { id : Int 32 | , name : String 33 | } 34 | 35 | 36 | type RemoteData a 37 | = Loading 38 | | Loaded a 39 | | OnError 40 | 41 | 42 | init : ( Model, Cmd Msg ) 43 | init = 44 | ( Model Loading, Cmd.none ) 45 | 46 | 47 | update : Msg -> Model -> ( Model, Cmd Msg ) 48 | update msg model = 49 | case msg of 50 | OnCategoriesFetched _ -> 51 | ( model, Cmd.none ) 52 | 53 | 54 | view : Model -> Html Msg 55 | view model = 56 | div [] 57 | [ h1 [] [ text "Play within a given category" ] 58 | , text "Loading the categories..." 59 | ] 60 | 61 | 62 | 63 | ------------------------------------------------------------------------------------------------------ 64 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 65 | ------------------------------------------------------------------------------------------------------ 66 | 67 | 68 | displayTestsAndView : Model -> Html Msg 69 | displayTestsAndView model = 70 | div [] 71 | [ styles 72 | , div [ class "jumbotron" ] [ view model ] 73 | , testsIframe 74 | ] 75 | -------------------------------------------------------------------------------- /Step08/README.md: -------------------------------------------------------------------------------- 1 | # Step 8: Categories List (part 2) 2 | 3 | ## Goal 4 | 5 | A lot of theory during the last 2 steps, it's time to come back to the categories page. But that was necessary, because we will now load our categories using a HTTP request... aka an *effect*! 6 | 7 | You're now ready for that! 8 | 9 | In the `CategoriesPage.elm` file, you can see that now, our categories page follow the Elm Architecture. Moreover, the categories list is not hardwritten in the code anymore, and does not appear! 10 | 11 | Your goal will now be to fetch this list through the [Trivia API](https://opentdb.com/api_config.php). The only thing you'll need to do is a GET request to [https://opentdb.com/api_category.php](https://opentdb.com/api_category.php). 12 | 13 | Our model is composed of a record with a key `categories` containing something of type `RemoteData String`. This is a custom type that we've defined above in the code, that allow us to represent our result by stating if our categories are loading, if we've had an error doing so, or if they are loaded successfully (in which case it does contain the result). Here, we're only trying to get a `String` and not a `List Category`. Because converting the result into a `List Category` is harder, we will do that in the next step. 14 | 15 | 16 | ## Instructions 17 | 18 | - When the page load, you need to trigger a HTTP call to get the categories list 19 | - This request will fetch the categories and get back a string representing the categories list as a JSON document 20 | - During the request, the screen should display the message "*Loading the categories...*" 21 | - If an error occurs, the screen should display the message "*An error occurred while loading the categories*" 22 | - When the categories are received, the screen should display the result as a string 23 | 24 | 25 | ## How do I perform HTTP requests? 26 | 27 | As for random numbers, HTTP requests are an effect that need to be asked with a command. For that, you will use the package `elm/http` already installed on the project. 28 | 29 | You will use the function [Http.get](https://package.elm-lang.org/packages/elm/http/latest/Http#get) that takes as argument a record containing two elements: 30 | - the URL to call 31 | - what the request should expect as a result 32 | 33 | As we're expecting a string, for the second element you can use [Http.expectString](https://package.elm-lang.org/packages/elm/http/latest/Http#expectString) that needs as argument a function of type `Result Error String -> Msg`. That's a function that receives a `Result Error String` and returns a message. 34 | 35 | Lucky us, that's exactly the signature of our message constructor `OnCategoriesFetched`! 36 | 37 | Small tip: The `Result error value` type is defined in the `Result` module like this: 38 | 39 | ```elm 40 | type Result error value 41 | = Ok value 42 | | Err error 43 | ``` 44 | 45 | It should be enough for you to extract data from it in a `case...of` expression, but you can find more information [there](https://package.elm-lang.org/packages/elm/core/latest/Result). 46 | 47 | 48 | ## Let's start! 49 | 50 | [See the result of your code](./CategoriesPage.elm) (don't forget to refresh to see changes) 51 | 52 | Once the tests are passing, you can go to the [next step](../Step09). 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /Step08/Solution/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step08.Solution.CategoriesPage exposing (Model, Msg(..), RemoteData(..), init, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, div, h1, text) 5 | import Http exposing (expectString) 6 | import Result exposing (Result) 7 | 8 | 9 | main : Program () Model Msg 10 | main = 11 | Browser.element 12 | { init = \_ -> init 13 | , update = update 14 | , view = view 15 | , subscriptions = \model -> Sub.none 16 | } 17 | 18 | 19 | type alias Model = 20 | { categories : RemoteData String 21 | } 22 | 23 | 24 | type Msg 25 | = OnCategoriesFetched (Result Http.Error String) 26 | 27 | 28 | type alias Category = 29 | { id : Int 30 | , name : String 31 | } 32 | 33 | 34 | type RemoteData a 35 | = Loading 36 | | Loaded a 37 | | OnError 38 | 39 | 40 | init : ( Model, Cmd Msg ) 41 | init = 42 | ( Model Loading, getCategories ) 43 | 44 | 45 | getCategories : Cmd Msg 46 | getCategories = 47 | Http.get { url = "https://opentdb.com/api_category.php", expect = expectString OnCategoriesFetched } 48 | 49 | 50 | update : Msg -> Model -> ( Model, Cmd Msg ) 51 | update msg model = 52 | case msg of 53 | OnCategoriesFetched (Ok categories) -> 54 | ( { model | categories = Loaded categories }, Cmd.none ) 55 | 56 | OnCategoriesFetched (Err _) -> 57 | ( { model | categories = OnError }, Cmd.none ) 58 | 59 | 60 | view : Model -> Html Msg 61 | view model = 62 | div [] 63 | [ h1 [] [ text "Play within a given category" ] 64 | , case model.categories of 65 | Loading -> 66 | text "Loading the categories..." 67 | 68 | OnError -> 69 | text "An error occurred while loading the categories..." 70 | 71 | Loaded categories -> 72 | text categories 73 | ] 74 | -------------------------------------------------------------------------------- /Step08/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step08.Tests.Tests exposing (main) 2 | 3 | import Expect 4 | import Fuzz 5 | import Html exposing (Html, div) 6 | import Http exposing (Error(..)) 7 | import Random 8 | import Step08.CategoriesPage as CategoriesPage exposing (Model, Msg(..), RemoteData(..)) 9 | import Test exposing (Test, concat, fuzz, test) 10 | import Test.Html.Selector as Selector 11 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 12 | import TestContext exposing (SimulatedEffect(..), TestContext, createWithSimulatedEffects, expectViewHas, update) 13 | import Utils.Utils exposing (testStyles) 14 | 15 | 16 | categoriesUrl : String 17 | categoriesUrl = 18 | "https://opentdb.com/api_category.php" 19 | 20 | 21 | categoriesPageProgram : TestContext Msg Model (Cmd Msg) 22 | categoriesPageProgram = 23 | createWithSimulatedEffects 24 | { init = CategoriesPage.init 25 | , update = CategoriesPage.update 26 | , view = CategoriesPage.view 27 | , deconstructEffect = \_ -> [ HttpRequest { method = "get", url = categoriesUrl } ] 28 | } 29 | 30 | 31 | main : Html a 32 | main = 33 | div [] 34 | [ testStyles 35 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 36 | ] 37 | 38 | 39 | suite : Test 40 | suite = 41 | concat 42 | [ theInitMethodShouldFetchCategories 43 | , theInitModelShouldBeLoading 44 | , whenTheCategoriesAreLoadingAMessageShouldSaySo 45 | , whenInitRequestFailTheCategoriesShouldBeOnError 46 | , whenInitRequestFailThereShouldBeAnError 47 | , whenInitRequestCompletesTheModelShouldBeUpdated 48 | , whenInitRequestCompletesTheResultShouldBeDisplayed 49 | ] 50 | 51 | 52 | theInitMethodShouldFetchCategories : Test 53 | theInitMethodShouldFetchCategories = 54 | test "The init method should return a `Cmd` (ideally to fetch categories, but this is not covered by this test)." <| 55 | \() -> 56 | Expect.false "The init method should return a Cmd" (Tuple.second CategoriesPage.init == Cmd.none) 57 | 58 | 59 | theInitModelShouldBeLoading : Test 60 | theInitModelShouldBeLoading = 61 | test "The init model should indicates that the categories are loading" <| 62 | \() -> 63 | Expect.equal (Model Loading) (Tuple.first CategoriesPage.init) 64 | 65 | 66 | whenTheCategoriesAreLoadingAMessageShouldSaySo : Test 67 | whenTheCategoriesAreLoadingAMessageShouldSaySo = 68 | test "When the request is loading, the following message should be displayed: \"Loading the categories...\"" <| 69 | \() -> 70 | categoriesPageProgram 71 | |> expectViewHas [ Selector.containing [ Selector.text "Loading the categories..." ] ] 72 | 73 | 74 | whenInitRequestFailTheCategoriesShouldBeOnError : Test 75 | whenInitRequestFailTheCategoriesShouldBeOnError = 76 | test "When the request fails, the model should keep track of that and there should be no command sent" <| 77 | \() -> 78 | let 79 | model = 80 | CategoriesPage.update (OnCategoriesFetched (Err NetworkError)) (Model Loading) 81 | in 82 | Expect.equal ( Model OnError, Cmd.none ) model 83 | 84 | 85 | whenInitRequestFailThereShouldBeAnError : Test 86 | whenInitRequestFailThereShouldBeAnError = 87 | test "When the request fails, the following error message should be displayed: \"An error occurred while loading the categories\"" <| 88 | \() -> 89 | categoriesPageProgram 90 | |> update (OnCategoriesFetched (Err NetworkError)) 91 | |> expectViewHas [ Selector.containing [ Selector.text "An error occurred while loading the categories" ] ] 92 | 93 | 94 | whenInitRequestCompletesTheModelShouldBeUpdated : Test 95 | whenInitRequestCompletesTheModelShouldBeUpdated = 96 | fuzz Fuzz.string "When the request completes, the model should store the string returned and there should be no command sent" <| 97 | \randomResponse -> 98 | let 99 | model = 100 | CategoriesPage.update (OnCategoriesFetched (Ok randomResponse)) (Model Loading) 101 | in 102 | Expect.equal ( Model (Loaded randomResponse), Cmd.none ) model 103 | 104 | 105 | whenInitRequestCompletesTheResultShouldBeDisplayed : Test 106 | whenInitRequestCompletesTheResultShouldBeDisplayed = 107 | fuzz Fuzz.string "When the request completes, the resulting string should be displayed" <| 108 | \randomResponse -> 109 | categoriesPageProgram 110 | |> update (OnCategoriesFetched (Ok randomResponse)) 111 | |> expectViewHas [ Selector.containing [ Selector.text randomResponse ] ] 112 | -------------------------------------------------------------------------------- /Step09/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step09.CategoriesPage exposing (Category, Model, Msg(..), RemoteData(..), categoriesListDecoder, displayCategories, displayCategory, getCategoriesDecoder, getCategoriesRequest, init, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Http exposing (expectJson) 7 | import Json.Decode as Decode 8 | import Result exposing (Result) 9 | import Utils.Utils exposing (styles, testsIframe) 10 | 11 | 12 | main : Program () Model Msg 13 | main = 14 | Browser.element { init = \_ -> init, update = update, view = displayTestsAndView, subscriptions = \model -> Sub.none } 15 | 16 | 17 | type alias Model = 18 | { categories : RemoteData (List Category) 19 | } 20 | 21 | 22 | type Msg 23 | = OnCategoriesFetched (Result Http.Error (List Category)) 24 | 25 | 26 | type alias Category = 27 | { id : Int 28 | , name : String 29 | } 30 | 31 | 32 | type RemoteData a 33 | = Loading 34 | | Loaded a 35 | | OnError 36 | 37 | 38 | init : ( Model, Cmd.Cmd Msg ) 39 | init = 40 | ( Model Loading, getCategoriesRequest ) 41 | 42 | 43 | getCategoriesRequest : Cmd Msg 44 | getCategoriesRequest = 45 | Http.get 46 | { url = "https://opentdb.com/api_category.php" 47 | , expect = expectJson OnCategoriesFetched getCategoriesDecoder 48 | } 49 | 50 | 51 | getCategoriesDecoder : Decode.Decoder (List Category) 52 | getCategoriesDecoder = 53 | Decode.field "trivia_categories" categoriesListDecoder 54 | 55 | 56 | categoriesListDecoder : Decode.Decoder (List Category) 57 | categoriesListDecoder = 58 | Decode.succeed [] 59 | 60 | 61 | update : Msg -> Model -> ( Model, Cmd.Cmd Msg ) 62 | update msg model = 63 | case msg of 64 | OnCategoriesFetched (Err error) -> 65 | ( Model OnError, Cmd.none ) 66 | 67 | OnCategoriesFetched (Ok categories) -> 68 | ( model, Cmd.none ) 69 | 70 | 71 | view : Model -> Html Msg 72 | view model = 73 | div [] 74 | [ h1 [] [ text "Play within a given category" ] 75 | , case model.categories of 76 | Loading -> 77 | text "Loading the categories..." 78 | 79 | OnError -> 80 | text "An error occurred while loading the categories" 81 | 82 | Loaded categories -> 83 | displayCategories categories 84 | ] 85 | 86 | 87 | displayCategories : List Category -> Html msg 88 | displayCategories categories = 89 | ul [ class "categories" ] (List.map displayCategory categories) 90 | 91 | 92 | displayCategory : Category -> Html.Html msg 93 | displayCategory category = 94 | let 95 | link = 96 | "#game/category/" ++ String.fromInt category.id 97 | in 98 | li [] 99 | [ a [ class "btn btn-primary", href link ] [ text category.name ] 100 | ] 101 | 102 | 103 | 104 | ------------------------------------------------------------------------------------------------------ 105 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 106 | ------------------------------------------------------------------------------------------------------ 107 | 108 | 109 | displayTestsAndView : Model -> Html Msg 110 | displayTestsAndView model = 111 | div [] 112 | [ styles 113 | , div [ class "jumbotron" ] [ view model ] 114 | , testsIframe 115 | ] 116 | -------------------------------------------------------------------------------- /Step09/README.md: -------------------------------------------------------------------------------- 1 | # Step 09: Categories List (final step) 2 | 3 | ## Goal 4 | 5 | We now have the categories list as a `String`, but we need it as a `List Category`. That's exactly what we will achieve in this step! 6 | 7 | 8 | ## JSON Decoders 9 | 10 | Elm has no runtime exception mainly thanks to its type system. Then how to interact with JSON strings, that are by nature really permissive? 11 | The answer is: each time we have to deal with a JSON, we will validate its structure thanks to a `Decoder`. 12 | 13 | Decode a JSON value into an Elm type means you need to tell the Elm runtime the way the JSON is structured and where to find each piece of information. 14 | 15 | For example, if you have a JSON like `{ firstname: "John", lastname: "Doe", birthYear: 1987 }`, you can tell Elm that there is a field `firstname` containing a `String` and a field `birthYear` containing an `Int`. 16 | If we don't need to use `lastname`, you don't need to mention it. 17 | 18 | Such a decoder could be created like below, thanks to the module [Json.Decode](https://package.elm-lang.org/packages/elm/json/latest/Json-Decode): 19 | 20 | ```elm 21 | import Json.Decode as Decode exposing (int, map2, field, string) 22 | 23 | type alias User = 24 | { firstname : String 25 | , birth : Int 26 | } 27 | 28 | userDecoder : Decode.Decoder User 29 | userDecoder = 30 | Decode.map2 31 | User 32 | (Decode.field "firstname" Decode.string) 33 | (Decode.field "birthYear" Decode.int) 34 | ``` 35 | 36 | As you can see, we are using `map2` to indicate that we will describe two fields. `User` is used as a constructor that will build our user thanks to the resulting values of the two decoders described below. 37 | If we had four fields to get, we would have used `map4`. 38 | 39 | Then we describe where to find the first of the two values, indicating that we are searching for a field `firstname` of type `String`. 40 | Then we describe where to find the second value, in a field `lastname` of type `Int`. 41 | 42 | Now that we have our decoder, how can we use it? For that you can use the function [`decodeString`](https://package.elm-lang.org/packages/elm/json/latest/Json-Decode#decodeString): 43 | 44 | ```elm 45 | jsonString : String 46 | jsonString = 47 | "{ firstname: \"John\", lastname: \"Doe\", birthYear: 1987 }" 48 | 49 | decodedUser : Result String User 50 | decodedUser = 51 | Decode.decodeString userDecoder jsonString 52 | ``` 53 | 54 | As you can see, this function returns a `Result`, so you have to handle the cases where the JSON string is not valid, or the values you were searching for are not found inside. 55 | 56 | 57 | ## A few tips 58 | 59 | You have now all the elements to decode our `String` into a `List Category`, but here are a few tips: 60 | 61 | - By replacing [`Http.expectString`](https://package.elm-lang.org/packages/elm/http/latest/Http#expectString) by [`Http.expectJson`](https://package.elm-lang.org/packages/elm/http/latest/Http#expectJson), you can directly provide a `Decoder` as the second argument to decode the received body 62 | - We are not decoding a single category, but a list of categories. Explore the [`Json.Decode`](https://package.elm-lang.org/packages/elm/json/latest/Json-Decode) documentation to find a function that could help! 63 | - Be careful, if you look at the response body, our categories list is inside a field `trivia_categories`: `{ "trivia_categories": [...] }`. You will need to indicate that in your decoder: 64 | 65 | ```elm 66 | getCategoriesDecoder = 67 | Decode.field "trivia_categories" categoriesListDecoder 68 | ``` 69 | 70 | ## Let's start! 71 | 72 | [See the result of your code](./CategoriesPage.elm) (don't forget to refresh to see changes) 73 | 74 | Once the tests are passing, you can go to the [next step](../Step10). 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /Step09/Solution/CategoriesPage.elm: -------------------------------------------------------------------------------- 1 | module Step09.Solution.CategoriesPage exposing (Category, Model, Msg(..), RemoteData(..), categoriesListDecoder, displayCategories, displayCategory, getCategoriesDecoder, getCategoriesRequest, init, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Http exposing (expectJson) 7 | import Json.Decode as Decode 8 | import Result exposing (Result) 9 | 10 | 11 | main : Program () Model Msg 12 | main = 13 | Browser.element { init = \_ -> init, update = update, view = view, subscriptions = \model -> Sub.none } 14 | 15 | 16 | type alias Model = 17 | { categories : RemoteData (List Category) 18 | } 19 | 20 | 21 | type Msg 22 | = OnCategoriesFetched (Result Http.Error (List Category)) 23 | 24 | 25 | type alias Category = 26 | { id : Int 27 | , name : String 28 | } 29 | 30 | 31 | type RemoteData a 32 | = Loading 33 | | Loaded a 34 | | OnError 35 | 36 | 37 | init : ( Model, Cmd.Cmd Msg ) 38 | init = 39 | ( Model Loading, getCategoriesRequest ) 40 | 41 | 42 | getCategoriesRequest : Cmd Msg 43 | getCategoriesRequest = 44 | Http.get 45 | { url = "https://opentdb.com/api_category.php" 46 | , expect = expectJson OnCategoriesFetched getCategoriesDecoder 47 | } 48 | 49 | 50 | getCategoriesDecoder : Decode.Decoder (List Category) 51 | getCategoriesDecoder = 52 | Decode.field "trivia_categories" categoriesListDecoder 53 | 54 | 55 | categoriesListDecoder : Decode.Decoder (List Category) 56 | categoriesListDecoder = 57 | Decode.list categoryDecoder 58 | 59 | 60 | categoryDecoder : Decode.Decoder Category 61 | categoryDecoder = 62 | Decode.map2 Category 63 | (Decode.field "id" Decode.int) 64 | (Decode.field "name" Decode.string) 65 | 66 | 67 | update : Msg -> Model -> ( Model, Cmd Msg ) 68 | update msg model = 69 | case msg of 70 | OnCategoriesFetched (Err _) -> 71 | ( Model OnError, Cmd.none ) 72 | 73 | OnCategoriesFetched (Ok categories) -> 74 | ( Model (Loaded categories), Cmd.none ) 75 | 76 | 77 | view : Model -> Html Msg 78 | view model = 79 | div [] 80 | [ h1 [] [ text "Play within a given category" ] 81 | , case model.categories of 82 | Loading -> 83 | text "Loading the categories..." 84 | 85 | OnError -> 86 | text "An error occurred while loading the categories" 87 | 88 | Loaded categories -> 89 | displayCategories categories 90 | ] 91 | 92 | 93 | displayCategories : List Category -> Html msg 94 | displayCategories categories = 95 | ul [ class "categories" ] (List.map displayCategory categories) 96 | 97 | 98 | displayCategory : Category -> Html msg 99 | displayCategory category = 100 | let 101 | link = 102 | "#game/category/" ++ String.fromInt category.id 103 | in 104 | li [] 105 | [ a [ class "btn btn-primary", href link ] [ text category.name ] 106 | ] 107 | -------------------------------------------------------------------------------- /Step09/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step09.Tests.Tests exposing (main) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz 5 | import Html exposing (Html, div) 6 | import Http exposing (Error(..)) 7 | import Json.Decode as Decode 8 | import Json.Encode as Encode 9 | import Random 10 | import Step09.CategoriesPage as CategoriesPage exposing (Category, Model, Msg(..), RemoteData(..), getCategoriesDecoder) 11 | import Test exposing (Test, concat, fuzz, test) 12 | import Test.Html.Selector as Selector exposing (Selector) 13 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 14 | import TestContext exposing (SimulatedEffect(..), TestContext, createWithSimulatedEffects, expectModel, expectViewHas, simulateLastEffect, update) 15 | import Utils.Utils exposing (testStyles) 16 | 17 | 18 | categoriesUrl : String 19 | categoriesUrl = 20 | "https://opentdb.com/api_category.php" 21 | 22 | 23 | categoriesPageProgram : TestContext Msg Model (Cmd Msg) 24 | categoriesPageProgram = 25 | createWithSimulatedEffects 26 | { init = CategoriesPage.init 27 | , update = CategoriesPage.update 28 | , view = CategoriesPage.view 29 | , deconstructEffect = \_ -> [ HttpRequest { method = "get", url = categoriesUrl } ] 30 | } 31 | 32 | 33 | main : Html a 34 | main = 35 | div [] 36 | [ testStyles 37 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 38 | ] 39 | 40 | 41 | suite : Test 42 | suite = 43 | concat 44 | [ theInitMethodShouldFetchCategories 45 | , theInitModelShouldBeLoading 46 | , whenTheCategoriesAreLoadingAMessageShouldSaySo 47 | , whenInitRequestFailTheCategoriesShouldBeOnError 48 | , whenInitRequestFailThereShouldBeAnError 49 | , theDecoderShouldProperlyDecodeCategoriesList 50 | , whenInitRequestCompletesTheModelShouldBeUpdated 51 | , whenInitRequestCompletesTheResultsShouldBeDisplayed 52 | ] 53 | 54 | 55 | theInitMethodShouldFetchCategories : Test 56 | theInitMethodShouldFetchCategories = 57 | test "The init method should return a `Cmd` (ideally to fetch categories, but this is not covered by this test)." <| 58 | \() -> 59 | Expect.false "The init method should return a command" (Tuple.second CategoriesPage.init == Cmd.none) 60 | 61 | 62 | theInitModelShouldBeLoading : Test 63 | theInitModelShouldBeLoading = 64 | test "The init model should indicates that the categories are loading" <| 65 | \() -> 66 | Expect.equal (Model Loading) (Tuple.first CategoriesPage.init) 67 | 68 | 69 | whenTheCategoriesAreLoadingAMessageShouldSaySo : Test 70 | whenTheCategoriesAreLoadingAMessageShouldSaySo = 71 | test "When the request is loading, the following message should be displayed: \"Loading the categories...\"" <| 72 | \() -> 73 | categoriesPageProgram 74 | |> expectViewHas [ Selector.containing [ Selector.text "Loading the categories..." ] ] 75 | 76 | 77 | whenInitRequestFailTheCategoriesShouldBeOnError : Test 78 | whenInitRequestFailTheCategoriesShouldBeOnError = 79 | test "When the request fails, the model should keep track of that and there should be no command sent" <| 80 | \() -> 81 | let 82 | model = 83 | CategoriesPage.update (OnCategoriesFetched (Err NetworkError)) (Model Loading) 84 | in 85 | Expect.equal ( Model OnError, Cmd.none ) model 86 | 87 | 88 | whenInitRequestFailThereShouldBeAnError : Test 89 | whenInitRequestFailThereShouldBeAnError = 90 | test "When the request fails, the following error message should be displayed: \"An error occurred while loading the categories\"" <| 91 | \() -> 92 | categoriesPageProgram 93 | |> update (OnCategoriesFetched (Err NetworkError)) 94 | |> expectViewHas [ Selector.containing [ Selector.text "An error occurred while loading the categories" ] ] 95 | 96 | 97 | theDecoderShouldProperlyDecodeCategoriesList : Test 98 | theDecoderShouldProperlyDecodeCategoriesList = 99 | fuzz randomCategoriesFuzz "The decoder should properly decode the categories list" <| 100 | \randomCategories -> 101 | let 102 | encodedCategories = 103 | encodeCategoriesList randomCategories 104 | in 105 | Decode.decodeValue getCategoriesDecoder encodedCategories 106 | |> Expect.equal (Ok randomCategories) 107 | 108 | 109 | whenInitRequestCompletesTheModelShouldBeUpdated : Test 110 | whenInitRequestCompletesTheModelShouldBeUpdated = 111 | fuzz randomCategoriesFuzz "When the request completes, the model should store the decoded categories" <| 112 | \randomCategories -> 113 | let 114 | expectedModel = 115 | Model (Loaded randomCategories) 116 | in 117 | categoriesPageProgram 118 | |> simulateLastEffect (\_ -> randomCategories |> Ok |> OnCategoriesFetched |> List.singleton |> Ok) 119 | |> expectModel (Expect.equal expectedModel) 120 | 121 | 122 | whenInitRequestCompletesTheResultsShouldBeDisplayed : Test 123 | whenInitRequestCompletesTheResultsShouldBeDisplayed = 124 | fuzz randomCategoriesFuzz "When the request completes, the categories should be displayed" <| 125 | \randomCategories -> 126 | let 127 | allCategoriesNamesArePresent : List Selector 128 | allCategoriesNamesArePresent = 129 | randomCategories 130 | |> List.map .name 131 | |> List.map Selector.text 132 | |> List.map List.singleton 133 | |> List.map Selector.containing 134 | |> Debug.log "test" 135 | in 136 | case randomCategories of 137 | -- Not asserting empty array because findAll makes it fail 138 | [] -> 139 | Expect.pass 140 | 141 | _ -> 142 | categoriesPageProgram 143 | |> simulateLastEffect (\_ -> randomCategories |> Ok |> OnCategoriesFetched |> List.singleton |> Ok) 144 | |> expectViewHas allCategoriesNamesArePresent 145 | 146 | 147 | randomCategoriesFuzz : Fuzz.Fuzzer (List Category) 148 | randomCategoriesFuzz = 149 | Fuzz.map2 Category Fuzz.int Fuzz.string 150 | |> Fuzz.list 151 | 152 | 153 | encodeCategoriesList : List Category -> Encode.Value 154 | encodeCategoriesList categories = 155 | categories 156 | |> Encode.list 157 | (\category -> 158 | Encode.object 159 | [ ( "id", Encode.int category.id ) 160 | , ( "name", Encode.string category.name ) 161 | ] 162 | ) 163 | |> (\encodedCategories -> Encode.object [ ( "trivia_categories", encodedCategories ) ]) 164 | -------------------------------------------------------------------------------- /Step10/README.md: -------------------------------------------------------------------------------- 1 | # Step 10: Routing 2 | 3 | ## Goal 4 | 5 | 6 | ## Navigation? 7 | 8 | When we're talking about *navigation* in a classic Single Page Application, it means that our application handles several pages with different URLs without reloading everything. 9 | 10 | This is possible by handling these three things: 11 | 12 | - When the page loads, we need to analyze the initial URL to display the proper page 13 | - When the user clicks on a link, we need to change the URL (= adding an entry to the browser history) 14 | - When the URL changes, we need to display the proper page 15 | 16 | This is quite easy to do with Elm, but we will need to use a more advanced program than `Browser.element`. The `Browser.navigation` program allows us these three things. Let's see how we can declare such a program: 17 | 18 | 19 | ```elm 20 | main : Program Value Model Msg 21 | main = 22 | Browser.application 23 | { init = init 24 | , view = view 25 | , update = update 26 | , subscriptions = subscriptions 27 | , onUrlRequest = OnUrlRequest 28 | , onUrlChange = OnUrlChange 29 | } 30 | ``` 31 | 32 | First thing we can notice is that we need to provide two new elements: `OnUrlRequest` and `OnUrlChange` that are both messages that can be defined like this: 33 | 34 | ```elm 35 | type Msg = 36 | OnUrlRequest UrlRequest 37 | | OnUrlChange Url 38 | ``` 39 | 40 | The first will be sent when a link is clicked and the second when an URL has changed (= when there is a new entry in the browser history). Great, that's two of the three things we need to handle! 41 | 42 | But these are not the only two elements that have changed, let's have a look at the signature of the `init` function: 43 | 44 | ```elm 45 | init : flags -> Url -> Key -> ( Model, Cmd Msg ) 46 | ``` 47 | 48 | `flags` is a value that we can get when starting our application (similar to arguments in a CLI tool for example) but we won't need that for now. However, we can see that we also receive a `Url` and a `Key`. This URL will allow us to define the initial page according to the URL. 49 | The `Key` part is something a bit special: we need to store it inside our model because it will be needed to use some functions of the [`Browser.Navigation` module](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation). 50 | 51 | You now have all you need to handle the routing in our application! 52 | 53 | ## What you need to do 54 | 55 | According to the page, you need to change the view... That means that your page should be stored inside the model. In your `init` function, you should check what the [`URL` object](https://package.elm-lang.org/packages/elm/url/latest/Url#Url) contains to store the proper page inside the model, along with the key. 56 | 57 | For that, our model has evolved: 58 | 59 | ```elm 60 | type Page 61 | = HomePage 62 | | CategoriesPage (RemoteData (List Category)) 63 | 64 | 65 | type alias Model = 66 | { key : Key 67 | , page : Page 68 | } 69 | ``` 70 | 71 | As you can see, our list of categories is now directly stored inside our `Page`, because we don't need that data when the page is loaded. 72 | 73 | After that, you need to handle a few things: 74 | 75 | - When a link is clicked, an `OnUrlRequest` message is triggered, containing a [`UrlRequest` object](https://package.elm-lang.org/packages/elm/browser/latest/Browser#UrlRequest). If you follow that link, you can see that it can either be an `External` link (targeted on another domain), or an `Internal` link. 76 | - If it is an `External` link, we want to navigate to the external page with the [`Browser.Navigation.load` function](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation#load). 77 | - It it is an `Internal` link, we want to add that entry to the browser history with the [`Browser.Navigation.pushUrl` function](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation#pushUrl). 78 | - This will trigger a `OnUrlChange` message with the new `Url` ; you need to change the `Page` inside our model according to the new URL 79 | - In the view function, you need to display the proper page according to the model (`displayHomePage` or `displayCategoriesPage`). 80 | 81 | __Small reminder:__ if during init or `OnUrlChange` the new page is the categories page, you also need to load the categories! 82 | 83 | __Notice:__ we will navigate using hash strategy, that means our URLs will be `http://localhost:8000/` and `http://localhost:8080/#categories`. Because of that, you need to react to the `fragment` part of the `URL` object. 84 | 85 | ## Let's start! 86 | 87 | [See the result of your code](./Routing.elm) (don't forget to refresh to see changes) 88 | 89 | Once the tests are passing, you can go to the [next step](../Step11). 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Step10/Routing.elm: -------------------------------------------------------------------------------- 1 | module Step10.Routing exposing (Category, Model, Msg(..), RemoteData(..), categoriesDecoder, displayCategoriesList, displayCategoriesPage, displayCategory, displayTestsAndView, getCategoriesRequest, getCategoriesUrl, init, main, update, view) 2 | 3 | import Browser exposing (Document, UrlRequest(..)) 4 | import Browser.Navigation as Navigation exposing (Key) 5 | import Html exposing (Html, a, div, h1, li, text, ul) 6 | import Html.Attributes exposing (class, href) 7 | import Http exposing (expectJson) 8 | import Json.Decode as Decode 9 | import Result exposing (Result) 10 | import Url exposing (Url) 11 | import Utils.Utils exposing (styles, testsIframe) 12 | 13 | 14 | main : Program () Model Msg 15 | main = 16 | Browser.application 17 | { init = init 18 | , update = update 19 | , view = displayTestsAndView 20 | , subscriptions = \model -> Sub.none 21 | , onUrlRequest = OnUrlRequest 22 | , onUrlChange = OnUrlChange 23 | } 24 | 25 | 26 | type Msg 27 | = OnCategoriesFetched (Result Http.Error (List Category)) 28 | | OnUrlRequest UrlRequest 29 | | OnUrlChange Url 30 | 31 | 32 | type Page 33 | = HomePage 34 | | CategoriesPage (RemoteData (List Category)) 35 | 36 | 37 | type alias Model = 38 | { key : Key 39 | , page : Page 40 | } 41 | 42 | 43 | type alias Category = 44 | { id : Int, name : String } 45 | 46 | 47 | type RemoteData a 48 | = Loading 49 | | Loaded a 50 | | OnError 51 | 52 | 53 | init : () -> Url -> Key -> ( Model, Cmd Msg ) 54 | init _ url key = 55 | ( Model key HomePage, getCategoriesRequest ) 56 | 57 | 58 | update : Msg -> Model -> ( Model, Cmd Msg ) 59 | update msg model = 60 | case msg of 61 | OnCategoriesFetched (Ok categories) -> 62 | ( model, Cmd.none ) 63 | 64 | OnCategoriesFetched (Err err) -> 65 | ( model, Cmd.none ) 66 | 67 | OnUrlRequest urlRequest -> 68 | ( model, Cmd.none ) 69 | 70 | OnUrlChange url -> 71 | ( model, Cmd.none ) 72 | 73 | 74 | getCategoriesUrl : String 75 | getCategoriesUrl = 76 | "https://opentdb.com/api_category.php" 77 | 78 | 79 | categoriesDecoder : Decode.Decoder (List Category) 80 | categoriesDecoder = 81 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 82 | |> Decode.list 83 | |> Decode.field "trivia_categories" 84 | 85 | 86 | getCategoriesRequest : Cmd Msg 87 | getCategoriesRequest = 88 | Http.get 89 | { url = getCategoriesUrl 90 | , expect = expectJson OnCategoriesFetched categoriesDecoder 91 | } 92 | 93 | 94 | view : Model -> Html Msg 95 | view model = 96 | div [] 97 | [ displayHomePage ] 98 | 99 | 100 | displayHomePage : Html Msg 101 | displayHomePage = 102 | div [ class "gameOptions" ] 103 | [ h1 [] [ text "Quiz Game" ] 104 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 105 | ] 106 | 107 | 108 | displayCategoriesPage : RemoteData (List Category) -> Html Msg 109 | displayCategoriesPage categories = 110 | div [] 111 | [ h1 [] [ text "Play within a given category" ] 112 | , displayCategoriesList categories 113 | ] 114 | 115 | 116 | displayCategoriesList : RemoteData (List Category) -> Html Msg 117 | displayCategoriesList categoriesRemote = 118 | case categoriesRemote of 119 | Loaded categories -> 120 | List.map displayCategory categories 121 | |> ul [ class "categories" ] 122 | 123 | OnError -> 124 | text "An error occurred while fetching categories" 125 | 126 | Loading -> 127 | text "Categories are loading..." 128 | 129 | 130 | displayCategory : Category -> Html Msg 131 | displayCategory category = 132 | let 133 | path = 134 | "#game/category/" ++ String.fromInt category.id 135 | in 136 | li [] 137 | [ a [ class "btn btn-primary", href path ] [ text category.name ] 138 | ] 139 | 140 | 141 | 142 | ------------------------------------------------------------------------------------------------------ 143 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 144 | ------------------------------------------------------------------------------------------------------ 145 | 146 | 147 | displayTestsAndView : Model -> Document Msg 148 | displayTestsAndView model = 149 | Document 150 | "Step 10" 151 | [ styles 152 | , div [ class "jumbotron" ] [ view model ] 153 | , testsIframe 154 | ] 155 | -------------------------------------------------------------------------------- /Step10/Solution/Routing.elm: -------------------------------------------------------------------------------- 1 | module Step10.Solution.Routing exposing (main) 2 | 3 | import Browser exposing (Document, UrlRequest(..)) 4 | import Browser.Navigation as Navigation exposing (Key) 5 | import Html exposing (Html, a, div, h1, li, text, ul) 6 | import Html.Attributes exposing (class, href) 7 | import Http exposing (expectJson) 8 | import Json.Decode as Decode 9 | import Result exposing (Result) 10 | import Url exposing (Url) 11 | import Utils.Utils exposing (styles) 12 | 13 | 14 | main : Program () Model Msg 15 | main = 16 | Browser.application 17 | { init = init 18 | , update = update 19 | , view = displayView 20 | , subscriptions = \model -> Sub.none 21 | , onUrlRequest = OnUrlRequest 22 | , onUrlChange = OnUrlChange 23 | } 24 | 25 | 26 | type Msg 27 | = OnCategoriesFetched (Result Http.Error (List Category)) 28 | | OnUrlRequest UrlRequest 29 | | OnUrlChange Url 30 | 31 | 32 | type Page 33 | = HomePage 34 | | CategoriesPage (RemoteData (List Category)) 35 | 36 | 37 | type alias Model = 38 | { key : Key 39 | , page : Page 40 | } 41 | 42 | 43 | type alias Category = 44 | { id : Int, name : String } 45 | 46 | 47 | type RemoteData a 48 | = Loading 49 | | Loaded a 50 | | OnError 51 | 52 | 53 | getPageAndCommand : Url -> ( Page, Cmd Msg ) 54 | getPageAndCommand url = 55 | case url.fragment of 56 | Just "categories" -> 57 | ( CategoriesPage Loading, getCategoriesRequest ) 58 | 59 | _ -> 60 | ( HomePage, Cmd.none ) 61 | 62 | 63 | init : () -> Url -> Key -> ( Model, Cmd Msg ) 64 | init _ url key = 65 | let 66 | ( page, cmd ) = 67 | getPageAndCommand url 68 | in 69 | ( Model key page, cmd ) 70 | 71 | 72 | update : Msg -> Model -> ( Model, Cmd Msg ) 73 | update msg model = 74 | case msg of 75 | OnCategoriesFetched (Ok categories) -> 76 | case model.page of 77 | CategoriesPage _ -> 78 | ( { model | page = CategoriesPage (Loaded categories) }, Cmd.none ) 79 | 80 | _ -> 81 | ( model, Cmd.none ) 82 | 83 | OnCategoriesFetched (Err err) -> 84 | case model.page of 85 | CategoriesPage _ -> 86 | ( { model | page = CategoriesPage OnError }, Cmd.none ) 87 | 88 | _ -> 89 | ( model, Cmd.none ) 90 | 91 | OnUrlRequest urlRequest -> 92 | case urlRequest of 93 | External url -> 94 | ( model, Navigation.load url ) 95 | 96 | Internal url -> 97 | ( model, Navigation.pushUrl model.key (Url.toString url) ) 98 | 99 | OnUrlChange url -> 100 | let 101 | ( page, cmd ) = 102 | getPageAndCommand url 103 | in 104 | ( { model | page = page }, cmd ) 105 | 106 | 107 | getCategoriesUrl : String 108 | getCategoriesUrl = 109 | "https://opentdb.com/api_category.php" 110 | 111 | 112 | categoriesDecoder : Decode.Decoder (List Category) 113 | categoriesDecoder = 114 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 115 | |> Decode.list 116 | |> Decode.field "trivia_categories" 117 | 118 | 119 | getCategoriesRequest : Cmd Msg 120 | getCategoriesRequest = 121 | Http.get 122 | { url = getCategoriesUrl 123 | , expect = expectJson OnCategoriesFetched categoriesDecoder 124 | } 125 | 126 | 127 | view : Model -> Html Msg 128 | view model = 129 | div [] 130 | [ case model.page of 131 | HomePage -> 132 | displayHomePage 133 | 134 | CategoriesPage categoriesModel -> 135 | displayCategoriesPage categoriesModel 136 | ] 137 | 138 | 139 | displayHomePage : Html Msg 140 | displayHomePage = 141 | div [ class "gameOptions" ] 142 | [ h1 [] [ text "Quiz Game" ] 143 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 144 | ] 145 | 146 | 147 | displayCategoriesPage : RemoteData (List Category) -> Html Msg 148 | displayCategoriesPage categories = 149 | div [] 150 | [ h1 [] [ text "Play within a given category" ] 151 | , displayCategoriesList categories 152 | ] 153 | 154 | 155 | displayCategoriesList : RemoteData (List Category) -> Html Msg 156 | displayCategoriesList categoriesRemote = 157 | case categoriesRemote of 158 | Loaded categories -> 159 | List.map displayCategory categories 160 | |> ul [ class "categories" ] 161 | 162 | OnError -> 163 | text "An error occurred while fetching categories" 164 | 165 | Loading -> 166 | text "Categories are loading..." 167 | 168 | 169 | displayCategory : Category -> Html Msg 170 | displayCategory category = 171 | let 172 | path = 173 | "#game/category/" ++ String.fromInt category.id 174 | in 175 | li [] 176 | [ a [ class "btn btn-primary", href path ] [ text category.name ] 177 | ] 178 | 179 | 180 | displayView : Model -> Document Msg 181 | displayView model = 182 | Document 183 | "Step 10" 184 | [ styles 185 | , view model 186 | ] 187 | -------------------------------------------------------------------------------- /Step10/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step10.Tests.Tests exposing (suite) 2 | 3 | import Browser exposing (Document, UrlRequest(..)) 4 | import Browser.Navigation exposing (Key) 5 | import Expect exposing (Expectation) 6 | import Random 7 | import Step10.Routing exposing (Category, Model, Msg(..), RemoteData(..), init, update, view) 8 | import Test exposing (Test, concat, test) 9 | import Test.Html.Query as Query 10 | import Test.Html.Selector exposing (text) 11 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 12 | import Url exposing (Protocol(..), Url) 13 | import Utils.Utils exposing (testStyles) 14 | 15 | 16 | type Msg 17 | = Noop 18 | 19 | 20 | main : Program () Key Msg 21 | main = 22 | let 23 | testsView key = 24 | Document 25 | "Tests for step 10" 26 | [ testStyles 27 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) (suite key) 28 | ] 29 | 30 | init _ _ key = 31 | ( key, Cmd.none ) 32 | 33 | update msg key = 34 | ( key, Cmd.none ) 35 | in 36 | Browser.application 37 | { init = init 38 | , update = update 39 | , view = testsView 40 | , subscriptions = always Sub.none 41 | , onUrlRequest = always Noop 42 | , onUrlChange = always Noop 43 | } 44 | 45 | 46 | fakeHomeUrl : Url 47 | fakeHomeUrl = 48 | { protocol = Http 49 | , host = "localhost" 50 | , port_ = Just 80 51 | , path = "/" 52 | , query = Nothing 53 | , fragment = Nothing 54 | } 55 | 56 | 57 | fakeCategoriesUrl : Url 58 | fakeCategoriesUrl = 59 | { fakeHomeUrl | fragment = Just "categories" } 60 | 61 | 62 | suite : Key -> Test 63 | suite key = 64 | concat 65 | [ atLoadingHomepageShouldBeDisplayed key 66 | , atLoadingCategoriesPageShouldBeDisplayed key 67 | , whenClickingToCategoriesLinkCategoriesShouldBeDisplayed key 68 | , atLoadingCategoriesShouldNotBeFetched key 69 | , atLoadingCategoriesShouldBeFetched key 70 | ] 71 | 72 | 73 | atLoadingHomepageShouldBeDisplayed : Key -> Test 74 | atLoadingHomepageShouldBeDisplayed key = 75 | test "When loading the page with home URL, the homepage should appear" <| 76 | \() -> 77 | let 78 | initialModel = 79 | init () fakeHomeUrl key |> Tuple.first 80 | in 81 | view initialModel 82 | |> Query.fromHtml 83 | |> Expect.all 84 | [ Query.has [ text "Quiz Game" ] 85 | , Query.has [ text "Play from a category" ] 86 | ] 87 | 88 | 89 | atLoadingCategoriesPageShouldBeDisplayed : Key -> Test 90 | atLoadingCategoriesPageShouldBeDisplayed key = 91 | test "When loading the page with categories URL, the categories page should appear" <| 92 | \() -> 93 | let 94 | initialModel = 95 | init () fakeCategoriesUrl key |> Tuple.first 96 | in 97 | view initialModel 98 | |> Query.fromHtml 99 | |> Expect.all 100 | [ Query.has [ text "Categories are loading" ] 101 | , Query.hasNot [ text "Play from a category" ] 102 | ] 103 | 104 | 105 | whenClickingToCategoriesLinkCategoriesShouldBeDisplayed : Key -> Test 106 | whenClickingToCategoriesLinkCategoriesShouldBeDisplayed key = 107 | test "When we go on the categories link (/#categories), the categories page should be displayed" <| 108 | \() -> 109 | let 110 | initialModel = 111 | init () fakeHomeUrl key 112 | |> Tuple.first 113 | 114 | updatedView = 115 | update (OnUrlChange fakeCategoriesUrl) initialModel 116 | |> Tuple.first 117 | |> view 118 | in 119 | updatedView 120 | |> Query.fromHtml 121 | |> Expect.all 122 | [ Query.has [ text "Categories are loading" ] 123 | , Query.hasNot [ text "Play from a category" ] 124 | ] 125 | 126 | 127 | atLoadingCategoriesShouldNotBeFetched : Key -> Test 128 | atLoadingCategoriesShouldNotBeFetched key = 129 | test "When loading the page with the home URL, the categories should not be fetched" <| 130 | \() -> 131 | Expect.equal Cmd.none (init () fakeHomeUrl key |> Tuple.second) 132 | 133 | 134 | atLoadingCategoriesShouldBeFetched : Key -> Test 135 | atLoadingCategoriesShouldBeFetched key = 136 | test "When loading the page with the categories URL, the categories should be fetched" <| 137 | \() -> 138 | Expect.notEqual Cmd.none (init () fakeCategoriesUrl key |> Tuple.second) 139 | -------------------------------------------------------------------------------- /Step11/ParsingRoute.elm: -------------------------------------------------------------------------------- 1 | module Step11.ParsingRoute exposing (Category, Msg, Page(..), RemoteData(..), Route(..), categoriesUrl, displayPage, displayTestsAndView, homeUrl, main, parseUrlToPageAndCommand, resultUrl, routeParser, view) 2 | 3 | import Html exposing (Html, div, text) 4 | import Html.Attributes exposing (class) 5 | import Http exposing (expectJson) 6 | import Json.Decode as Decode 7 | import Url exposing (Protocol(..), Url) 8 | import Url.Parser as Parser exposing (()) 9 | import Utils.Utils exposing (styles, testsIframe) 10 | 11 | 12 | main : Html Msg 13 | main = 14 | displayTestsAndView 15 | 16 | 17 | type Page 18 | = HomePage 19 | | CategoriesPage (RemoteData (List Category)) 20 | | ResultPage Int 21 | 22 | 23 | type Route 24 | = HomeRoute 25 | | CategoriesRoute 26 | | ResultRoute Int 27 | 28 | 29 | type alias Category = 30 | { id : Int, name : String } 31 | 32 | 33 | type RemoteData a 34 | = Loading 35 | | Loaded a 36 | | OnError 37 | 38 | 39 | type Msg 40 | = OnCategoriesFetched (Result Http.Error (List Category)) 41 | 42 | 43 | homeUrl : Url 44 | homeUrl = 45 | { protocol = Http 46 | , host = "localhost" 47 | , port_ = Just 80 48 | , path = "/" 49 | , query = Nothing 50 | , fragment = Nothing 51 | } 52 | 53 | 54 | categoriesUrl : Url 55 | categoriesUrl = 56 | { homeUrl | path = "/categories" } 57 | 58 | 59 | resultUrl : Int -> Url 60 | resultUrl score = 61 | { homeUrl | path = "/result/" ++ String.fromInt score } 62 | 63 | 64 | routeParser : Parser.Parser (Route -> Route) Route 65 | routeParser = 66 | Parser.oneOf 67 | [ Parser.map HomeRoute Parser.top ] 68 | 69 | 70 | parseUrlToPageAndCommand : Url -> ( Page, Cmd Msg ) 71 | parseUrlToPageAndCommand url = 72 | let 73 | routeMaybe : Maybe Route 74 | routeMaybe = 75 | Parser.parse routeParser url 76 | in 77 | ( HomePage, Cmd.none ) 78 | 79 | 80 | 81 | ------------------------------------------------------------------------------------------------------ 82 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 83 | ------------------------------------------------------------------------------------------------------ 84 | 85 | 86 | view : Html Msg 87 | view = 88 | div [] 89 | [ div [] 90 | [ text "Parsing url \"\": " 91 | , displayPage (parseUrlToPageAndCommand homeUrl) 92 | ] 93 | , div [] 94 | [ text "Parsing url \"/categories\": " 95 | , displayPage (parseUrlToPageAndCommand categoriesUrl) 96 | ] 97 | , div [] 98 | [ text "Parsing url \"/result/3\": " 99 | , displayPage (parseUrlToPageAndCommand <| resultUrl 3) 100 | ] 101 | , div [] 102 | [ text "Parsing url \"/result/4\": " 103 | , displayPage (parseUrlToPageAndCommand <| resultUrl 4) 104 | ] 105 | ] 106 | 107 | 108 | displayPage : ( Page, Cmd Msg ) -> Html msg 109 | displayPage ( page, cmd ) = 110 | let 111 | pageString = 112 | case page of 113 | HomePage -> 114 | "Home page" 115 | 116 | CategoriesPage Loading -> 117 | "Categories page with Loading status and" 118 | 119 | CategoriesPage _ -> 120 | "Categories page with wrong status and" 121 | 122 | ResultPage score -> 123 | "Results page with score " ++ String.fromInt score ++ " and" 124 | 125 | commandString = 126 | if cmd /= Cmd.none then 127 | "with a command" 128 | 129 | else 130 | "with no command" 131 | in 132 | text 133 | (pageString 134 | ++ " " 135 | ++ commandString 136 | ) 137 | 138 | 139 | getCategoriesUrl : String 140 | getCategoriesUrl = 141 | "https://opentdb.com/api_category.php" 142 | 143 | 144 | categoriesDecoder : Decode.Decoder (List Category) 145 | categoriesDecoder = 146 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 147 | |> Decode.list 148 | |> Decode.field "trivia_categories" 149 | 150 | 151 | getCategoriesRequest : Cmd Msg 152 | getCategoriesRequest = 153 | Http.get 154 | { url = getCategoriesUrl 155 | , expect = expectJson OnCategoriesFetched categoriesDecoder 156 | } 157 | 158 | 159 | displayTestsAndView : Html Msg 160 | displayTestsAndView = 161 | div [] 162 | [ styles 163 | , div [ class "jumbotron" ] [ view ] 164 | , testsIframe 165 | ] 166 | -------------------------------------------------------------------------------- /Step11/README.md: -------------------------------------------------------------------------------- 1 | # Step 11: Parsing the URL 2 | 3 | ## Goal 4 | 5 | For now, we have only handled static URLs, for example `#categories`. But how can we handle URL containing dynamic parameters? For example, our results page URL contains the score: `#result/3` or `#result/5`. 6 | 7 | For these more advanced routes, we need to use a parser and more exactly the [`Url.Parser` module](https://package.elm-lang.org/packages/elm/url/latest/Url-Parser). 8 | 9 | This is pretty much the same thing that with JSON decoders – we're describing what we expect to find and we check that pattern against the URL. 10 | 11 | For example, the following parser can be used to recognize the path `/categories/13/details`: `Parser.s "categories" Parser.int s "details"`. Here is how you could use it:= 12 | 13 | ```elm 14 | type Route = 15 | HomeRoute 16 | | CategoryDetailsRoute Int 17 | 18 | categoryDetailsParser : Parser.Parser (Route -> Route) Route 19 | categoryDetailsParser = 20 | Parser.s "categories" Parser.int Parser.s "details" 21 | |> Parser.map CategoryDetailsRoute 22 | 23 | 24 | parseUrl : Url -> Route 25 | parseUrl url = 26 | Parser.parse categoryDetailsParser url 27 | |> Maybe.withDefault HomeRoute 28 | ``` 29 | 30 | ## Parsing several routes 31 | 32 | Now it's your turn to parse the URL of our application to recognize those three paths: 33 | 34 | - the root path should be recognized as the `HomeRoute` ([`Parser.top`](https://package.elm-lang.org/packages/elm/url/latest/Url-Parser#top) may help you) 35 | - the path `categories` should be recognized as the `CategoriesRoute` 36 | - the path `result/2` should be recognized as the `ResultRoute 2` (the `2` is of course a dynamic value) 37 | 38 | To create a parser able to recognize several routes, you will need to use the [`Parser.oneOf` function](https://package.elm-lang.org/packages/elm/url/latest/Url-Parser#oneOf). 39 | 40 | 41 | ## Turning a route into a page 42 | 43 | After having parsed the URL into a `Route`, a simple `case ... of` syntax will allow you to get the matching `Page` and to get a command if we need to (remember, on the `CategoriesPage`, we need to get the categories). 44 | 45 | That's all, you can now modify the `routeParser` and the function `parseUrlToPageAndCommand` to make the tests pass. 46 | 47 | ## Let's start! 48 | 49 | [See the result of your code](./ParsingRoute.elm) (don't forget to refresh to see changes) 50 | 51 | Once the tests are passing, you can go to the [next step](../Step12). 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /Step11/Solution/ParsingRoute.elm: -------------------------------------------------------------------------------- 1 | module Step11.Solution.ParsingRoute exposing (main) 2 | 3 | import Html exposing (Html, div, text) 4 | import Html.Attributes exposing (class) 5 | import Http exposing (expectJson) 6 | import Json.Decode as Decode 7 | import Url exposing (Protocol(..), Url) 8 | import Url.Parser as Parser exposing (()) 9 | import Utils.Utils exposing (styles, testsIframe) 10 | 11 | 12 | main : Html Msg 13 | main = 14 | displayView 15 | 16 | 17 | type Page 18 | = HomePage 19 | | CategoriesPage (RemoteData (List Category)) 20 | | ResultPage Int 21 | 22 | 23 | type Route 24 | = HomeRoute 25 | | CategoriesRoute 26 | | ResultRoute Int 27 | 28 | 29 | type alias Category = 30 | { id : Int, name : String } 31 | 32 | 33 | type RemoteData a 34 | = Loading 35 | | Loaded a 36 | | OnError 37 | 38 | 39 | type Msg 40 | = OnCategoriesFetched (Result Http.Error (List Category)) 41 | 42 | 43 | homeUrl : Url 44 | homeUrl = 45 | { protocol = Http 46 | , host = "localhost" 47 | , port_ = Just 80 48 | , path = "/" 49 | , query = Nothing 50 | , fragment = Nothing 51 | } 52 | 53 | 54 | categoriesUrl : Url 55 | categoriesUrl = 56 | { homeUrl | path = "/categories" } 57 | 58 | 59 | resultUrl : Int -> Url 60 | resultUrl score = 61 | { homeUrl | path = "/result/" ++ String.fromInt score } 62 | 63 | 64 | routeParser : Parser.Parser (Route -> Route) Route 65 | routeParser = 66 | Parser.oneOf 67 | [ Parser.map HomeRoute Parser.top 68 | , Parser.map CategoriesRoute (Parser.s "categories") 69 | , Parser.map ResultRoute (Parser.s "result" Parser.int) 70 | ] 71 | 72 | 73 | parseUrlToPageAndCommand : Url -> ( Page, Cmd Msg ) 74 | parseUrlToPageAndCommand url = 75 | let 76 | routeMaybe : Maybe Route 77 | routeMaybe = 78 | Parser.parse routeParser url 79 | in 80 | case routeMaybe of 81 | Just CategoriesRoute -> 82 | ( CategoriesPage Loading, getCategoriesRequest ) 83 | 84 | Just (ResultRoute score) -> 85 | ( ResultPage score, Cmd.none ) 86 | 87 | Just HomeRoute -> 88 | ( HomePage, Cmd.none ) 89 | 90 | Nothing -> 91 | ( HomePage, Cmd.none ) 92 | 93 | 94 | view : Html Msg 95 | view = 96 | div [] 97 | [ div [] 98 | [ text "Parsing url \"\": " 99 | , displayPage (parseUrlToPageAndCommand homeUrl) 100 | ] 101 | , div [] 102 | [ text "Parsing url \"/categories\": " 103 | , displayPage (parseUrlToPageAndCommand categoriesUrl) 104 | ] 105 | , div [] 106 | [ text "Parsing url \"/result/3\": " 107 | , displayPage (parseUrlToPageAndCommand <| resultUrl 3) 108 | ] 109 | , div [] 110 | [ text "Parsing url \"/result/4\": " 111 | , displayPage (parseUrlToPageAndCommand <| resultUrl 4) 112 | ] 113 | ] 114 | 115 | 116 | displayPage : ( Page, Cmd Msg ) -> Html msg 117 | displayPage ( page, cmd ) = 118 | let 119 | pageString = 120 | case page of 121 | HomePage -> 122 | "Home page" 123 | 124 | CategoriesPage Loading -> 125 | "Categories page with Loading status and" 126 | 127 | CategoriesPage _ -> 128 | "Categories page with wrong status and" 129 | 130 | ResultPage score -> 131 | "Results page with score " ++ String.fromInt score ++ " and" 132 | 133 | commandString = 134 | if cmd /= Cmd.none then 135 | "with a command" 136 | 137 | else 138 | "with no command" 139 | in 140 | pageString 141 | ++ " " 142 | ++ commandString 143 | |> text 144 | 145 | 146 | getCategoriesUrl : String 147 | getCategoriesUrl = 148 | "https://opentdb.com/api_category.php" 149 | 150 | 151 | categoriesDecoder : Decode.Decoder (List Category) 152 | categoriesDecoder = 153 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 154 | |> Decode.list 155 | |> Decode.field "trivia_categories" 156 | 157 | 158 | getCategoriesRequest : Cmd Msg 159 | getCategoriesRequest = 160 | Http.get 161 | { url = getCategoriesUrl 162 | , expect = expectJson OnCategoriesFetched categoriesDecoder 163 | } 164 | 165 | 166 | displayView : Html Msg 167 | displayView = 168 | div [] 169 | [ styles 170 | , div [ class "jumbotron" ] [ view ] 171 | ] 172 | -------------------------------------------------------------------------------- /Step11/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step11.Tests.Tests exposing (suite) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz 5 | import Html exposing (Html, div) 6 | import Random 7 | import Step11.ParsingRoute exposing (Page(..), RemoteData(..), parseUrlToPageAndCommand) 8 | import Test exposing (Test, concat, fuzz, test) 9 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 10 | import Url exposing (Protocol(..), Url) 11 | import Utils.Utils exposing (testStyles) 12 | 13 | 14 | main : Html msg 15 | main = 16 | div [] 17 | [ testStyles 18 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) suite 19 | ] 20 | 21 | 22 | fakeHomeUrl : Url 23 | fakeHomeUrl = 24 | { protocol = Http 25 | , host = "localhost" 26 | , port_ = Just 80 27 | , path = "/" 28 | , query = Nothing 29 | , fragment = Nothing 30 | } 31 | 32 | 33 | fakeCategoriesUrl : Url 34 | fakeCategoriesUrl = 35 | { fakeHomeUrl | path = "categories" } 36 | 37 | 38 | fakeResultUrl : Int -> Url 39 | fakeResultUrl score = 40 | { fakeHomeUrl | path = "result/" ++ String.fromInt score } 41 | 42 | 43 | suite : Test 44 | suite = 45 | concat 46 | [ shouldProperlyParseHomePage 47 | , shouldProperlyParseCategoriesPage 48 | , shouldProperlyParseResultPage 49 | ] 50 | 51 | 52 | shouldProperlyParseHomePage : Test 53 | shouldProperlyParseHomePage = 54 | test "Should parse home URL into HomePage with no command" <| 55 | \() -> 56 | Expect.equal ( HomePage, Cmd.none ) (parseUrlToPageAndCommand fakeHomeUrl) 57 | 58 | 59 | shouldProperlyParseCategoriesPage : Test 60 | shouldProperlyParseCategoriesPage = 61 | test "Should parse categories URL into CategoriesPage with loading categories and with a command" <| 62 | \() -> 63 | Expect.all 64 | [ Tuple.first >> Expect.equal (CategoriesPage Loading) 65 | , Tuple.second >> Expect.notEqual Cmd.none 66 | ] 67 | (parseUrlToPageAndCommand fakeCategoriesUrl) 68 | 69 | 70 | shouldProperlyParseResultPage : Test 71 | shouldProperlyParseResultPage = 72 | fuzz (Fuzz.intRange 0 5) "Should parse result URL into ResultPage with given score and with no command" <| 73 | \score -> 74 | Expect.equal ( ResultPage score, Cmd.none ) (parseUrlToPageAndCommand (fakeResultUrl score)) 75 | -------------------------------------------------------------------------------- /Step12/GamePage.elm: -------------------------------------------------------------------------------- 1 | module Step12.GamePage exposing (Question, gamePage, main, questionToDisplay) 2 | 3 | import Html exposing (Html, a, div, h2, li, text, ul) 4 | import Html.Attributes exposing (class) 5 | import Utils.Utils exposing (styles, testsIframe) 6 | 7 | 8 | type alias Question = 9 | { question : String 10 | , correctAnswer : String 11 | , answers : List String 12 | } 13 | 14 | 15 | questionToDisplay = 16 | { question = "What doesn't exist in Elm?" 17 | , correctAnswer = "Runtime exceptions" 18 | , answers = [ "Runtime exceptions", "JSON", "Single page applications", "Happy developers" ] 19 | } 20 | 21 | 22 | gamePage : Question -> Html msg 23 | gamePage question = 24 | div [] [ text "Content of the page" ] 25 | 26 | 27 | 28 | ------------------------------------------------------------------------------------------------------ 29 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 30 | ------------------------------------------------------------------------------------------------------ 31 | 32 | 33 | main : Html msg 34 | main = 35 | div [] 36 | [ styles 37 | , div [ class "jumbotron" ] [ gamePage questionToDisplay ] 38 | , testsIframe 39 | ] 40 | -------------------------------------------------------------------------------- /Step12/README.md: -------------------------------------------------------------------------------- 1 | # Step 12: Game page 2 | 3 | ## Goal 4 | 5 | Our quiz game can display the home page, the list of categories, the result page, but we can't even display questions! Let's do that! 6 | 7 | ![Screenshot of the game page](../Utils/images/step12.png) 8 | 9 | Here is the HTML code you need to have: 10 | 11 | ```html 12 |
13 |

Question here

14 | 20 |
21 | ``` 22 | 23 | Everything is static for now, we will add the logic in the next steps. 24 | 25 | 26 | ## Let's start! 27 | 28 | [See the result of your code](./GamePage.elm) (don't forget to refresh to see changes) 29 | 30 | Once the tests are passing, you can go to the [next step](../Step13). 31 | 32 | -------------------------------------------------------------------------------- /Step12/Solution/GamePage.elm: -------------------------------------------------------------------------------- 1 | module Step12.Solution.GamePage exposing (Question, displayAnswer, gamePage, main, questionToDisplay) 2 | 3 | import Html exposing (Html, a, div, h2, li, text, ul) 4 | import Html.Attributes exposing (class) 5 | import Utils.Utils exposing (styles) 6 | 7 | 8 | type alias Question = 9 | { question : String 10 | , correctAnswer : String 11 | , answers : List String 12 | } 13 | 14 | 15 | questionToDisplay = 16 | { question = "What doesn't exist in Elm?" 17 | , correctAnswer = "Runtime exceptions" 18 | , answers = [ "Runtime exceptions", "JSON", "Single page applications", "Happy developers" ] 19 | } 20 | 21 | 22 | gamePage : Question -> Html msg 23 | gamePage question = 24 | div [] 25 | [ h2 [ class "question" ] [ text question.question ] 26 | , ul [ class "answers" ] (List.map displayAnswer question.answers) 27 | ] 28 | 29 | 30 | displayAnswer : String -> Html msg 31 | displayAnswer answer = 32 | li [] 33 | [ a [ class "btn btn-primary" ] [ text answer ] 34 | ] 35 | 36 | 37 | 38 | ------------------------------------------------------------------------------------------------------ 39 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 40 | ------------------------------------------------------------------------------------------------------ 41 | 42 | 43 | main : Html msg 44 | main = 45 | div [] 46 | [ styles 47 | , div [ class "jumbotron" ] [ gamePage questionToDisplay ] 48 | ] 49 | -------------------------------------------------------------------------------- /Step12/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step12.Tests.Tests exposing (main) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz 5 | import Html exposing (Html, div) 6 | import Random 7 | import Step12.GamePage exposing (Question, gamePage) 8 | import Test exposing (Test, concat, fuzz) 9 | import Test.Html.Query as Query 10 | import Test.Html.Selector exposing (Selector, class, classes, tag, text) 11 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 12 | import Utils.Utils exposing (testStyles) 13 | 14 | 15 | main : Html msg 16 | main = 17 | div [] 18 | [ testStyles 19 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) testsSuite 20 | ] 21 | 22 | 23 | testsSuite : Test 24 | testsSuite = 25 | concat 26 | [ questionIsDisplayedIntoAH2Tag 27 | , theH2TagHasClassQuestion 28 | , answersAreDisplayedInsideAListWithClassAnswers 29 | , answersAreDisplayedInsideLiTags 30 | , answersHaveProperClasses 31 | ] 32 | 33 | 34 | questionIsDisplayedIntoAH2Tag : Test 35 | questionIsDisplayedIntoAH2Tag = 36 | fuzz questionFuzzer "The question is displayed inside an `h2` tag" <| 37 | \question -> 38 | gamePage question 39 | |> Query.fromHtml 40 | |> Query.find [ tag "h2" ] 41 | |> Query.has [ text question.question ] 42 | 43 | 44 | theH2TagHasClassQuestion : Test 45 | theH2TagHasClassQuestion = 46 | fuzz questionFuzzer "The `h2` tag has the class \"question\"" <| 47 | \question -> 48 | gamePage question 49 | |> Query.fromHtml 50 | |> Query.find [ tag "h2" ] 51 | |> Query.has [ class "question" ] 52 | 53 | 54 | answersAreDisplayedInsideAListWithClassAnswers : Test 55 | answersAreDisplayedInsideAListWithClassAnswers = 56 | fuzz questionFuzzer "The answers are displayed inside a `ul` tag with the class \"answers\"" <| 57 | \question -> 58 | gamePage question 59 | |> Query.fromHtml 60 | |> Query.find [ tag "ul" ] 61 | |> Query.has [ class "answers" ] 62 | 63 | 64 | answersAreDisplayed : Test 65 | answersAreDisplayed = 66 | fuzz questionFuzzer "The answers are displayed" <| 67 | \question -> 68 | let 69 | expectations : List (Query.Single msg -> Expectation) 70 | expectations = 71 | List.map (text >> List.singleton >> Query.has) question.answers 72 | in 73 | gamePage question 74 | |> Query.fromHtml 75 | |> Expect.all expectations 76 | 77 | 78 | answersAreDisplayedInsideLiTags : Test 79 | answersAreDisplayedInsideLiTags = 80 | fuzz questionFuzzer "The answers are displayed each inside a `li`" <| 81 | \question -> 82 | gamePage question 83 | |> Query.fromHtml 84 | |> Query.findAll [ tag "li" ] 85 | |> Query.count (Expect.equal 4) 86 | 87 | 88 | answersHaveProperClasses : Test 89 | answersHaveProperClasses = 90 | fuzz questionFuzzer "The answers have a link with classes \"btn btn-primary\"" <| 91 | \question -> 92 | gamePage question 93 | |> Query.fromHtml 94 | |> Query.findAll [ tag "li" ] 95 | |> Query.each (Query.has [ tag "a", classes [ "btn", "btn-primary" ] ]) 96 | 97 | 98 | questionFuzzer : Fuzz.Fuzzer Question 99 | questionFuzzer = 100 | Fuzz.map5 101 | (\question answer1 answer2 answer3 answer4 -> 102 | Question question 103 | answer1 104 | [ answer1 105 | , answer2 106 | , answer3 107 | , answer4 108 | ] 109 | ) 110 | Fuzz.string 111 | Fuzz.string 112 | Fuzz.string 113 | Fuzz.string 114 | Fuzz.string 115 | -------------------------------------------------------------------------------- /Step13/GamePage.elm: -------------------------------------------------------------------------------- 1 | module Step13.GamePage exposing (Category, Game, Model, Msg(..), Question, RemoteData(..), displayAnswer, displayTestsAndView, gamePage, init, main, questionsUrl, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, a, div, h2, li, text, ul) 5 | import Html.Attributes exposing (class) 6 | import Http exposing (Error, expectJson) 7 | import Utils.Utils exposing (styles, testsIframe) 8 | 9 | 10 | questionsUrl : String 11 | questionsUrl = 12 | "https://opentdb.com/api.php?amount=5&type=multiple" 13 | 14 | 15 | main : Program () Model Msg 16 | main = 17 | Browser.element 18 | { init = \_ -> init 19 | , update = update 20 | , view = displayTestsAndView 21 | , subscriptions = \model -> Sub.none 22 | } 23 | 24 | 25 | type alias Question = 26 | { question : String 27 | , correctAnswer : String 28 | , answers : List String 29 | } 30 | 31 | 32 | type alias Model = 33 | { game : RemoteData Game 34 | } 35 | 36 | 37 | type alias Game = 38 | { currentQuestion : Question 39 | , remainingQuestions : List Question 40 | } 41 | 42 | 43 | type Msg 44 | = OnQuestionsFetched (Result Http.Error (List Question)) 45 | 46 | 47 | type alias Category = 48 | { id : Int 49 | , name : String 50 | } 51 | 52 | 53 | type RemoteData a 54 | = Loading 55 | | Loaded a 56 | | OnError 57 | 58 | 59 | init : ( Model, Cmd Msg ) 60 | init = 61 | ( Model Loading, Cmd.none ) 62 | 63 | 64 | update : Msg -> Model -> ( Model, Cmd Msg ) 65 | update message model = 66 | case message of 67 | OnQuestionsFetched _ -> 68 | ( model, Cmd.none ) 69 | 70 | 71 | view : Model -> Html.Html Msg 72 | view model = 73 | div [] [ text "Content of the page" ] 74 | 75 | 76 | gamePage : Question -> Html msg 77 | gamePage question = 78 | div [] 79 | [ h2 [ class "question" ] [ text question.question ] 80 | , ul [ class "answers" ] (List.map displayAnswer question.answers) 81 | ] 82 | 83 | 84 | displayAnswer : String -> Html msg 85 | displayAnswer answer = 86 | li [] [ a [ class "btn btn-primary" ] [ text answer ] ] 87 | 88 | 89 | 90 | ------------------------------------------------------------------------------------------------------ 91 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 92 | ------------------------------------------------------------------------------------------------------ 93 | 94 | 95 | displayTestsAndView : Model -> Html Msg 96 | displayTestsAndView model = 97 | div [] 98 | [ styles 99 | , div [ class "jumbotron" ] [ view model ] 100 | , testsIframe 101 | ] 102 | -------------------------------------------------------------------------------- /Step13/README.md: -------------------------------------------------------------------------------- 1 | # Step 13: Game page 2 | 3 | ## Goal 4 | 5 | We know how to display a question, now we need to get the list of questions from the Trivia API. 6 | By default, we will get 5 questions of any category, filtering to only keep multiple choices questions. This is the URL you will need to call: `https://opentdb.com/api.php?amount=5&type=multiple`. 7 | 8 | As you can see, it returns an answer with the following format: 9 | 10 | ```js 11 | { 12 | results: [ 13 | { 14 | category: "Science & Nature", 15 | type: "multiple", 16 | difficulty: "medium", 17 | question: "To the nearest minute, how long does it take for light to travel from the Sun to the Earth?", 18 | correct_answer: "8 Minutes", 19 | incorrect_answers: [ 20 | "6 Minutes", 21 | "2 Minutes", 22 | "12 Minutes" 23 | ] 24 | }, 25 | // [...] 26 | ] 27 | } 28 | ``` 29 | 30 | We don't need all these informations, because our model contains only three fields: 31 | 32 | ```elm 33 | type alias Question = 34 | { question : String 35 | , correctAnswer : String 36 | , answers : List String 37 | } 38 | ``` 39 | 40 | Be careful, our `answers` field does not exactly match the `incorrect_answers` field of the JSON response, because it also contains the correct answer. That means you will need an extra step in the decoder to compute this field, maybe you will find a way in the [`Json.Decode` module documentation](https://package.elm-lang.org/packages/elm/json/latest/Json-Decode)... 41 | 42 | Let's keep it simple by adding the correct answer at the head of the list, meaning that the first answer displayed will always be the correct one. We will fix that flaw later. 43 | 44 | ## A new model 45 | 46 | By looking at the code, you can see a new model: 47 | 48 | ```elm 49 | type alias Model = 50 | { game : RemoteData Game 51 | } 52 | 53 | type alias Game = 54 | { currentQuestion : Question 55 | , remainingQuestions : List Question 56 | } 57 | ``` 58 | 59 | As you can see, the questions have been stored inside a new `Game` type and are split in two: the current question and a list of remaining questions (that does not contain the current question). 60 | 61 | This choice can seem weird, so let's see what are the alternatives to better understand it: 62 | 63 | ### Alternative 1 64 | 65 | The first alternative is the following one: 66 | 67 | ```elm 68 | type alias Game = 69 | { currentQuestion : Int 70 | , questions : List Question 71 | } 72 | ``` 73 | 74 | We're keeping all the questions in a list and only storing the index of the current question. That model could work, but it has a main flaw: it's possible to represent **impossible states**. 75 | 76 | For example, let's imagine that for an unknown reason, we have 5 questions in our list and that our `currentQuestion` index is 6. This state is not possible and should not happen in the application. That means we need to make sure that we will never set this value to an invalid number. What if we don't have to, because the model does not allow this? 77 | 78 | If you look at the retained model, you can't have such an impossible case! 79 | 80 | 81 | ### Alternative 2 82 | 83 | ```elm 84 | type alias Game = 85 | { remainingQuestions : List Question 86 | } 87 | ``` 88 | 89 | Here, we're only keeping the remaining questions, and by convention the first one is the current question. Once this question has been answered, it is removed from the list and the next question become the current one. 90 | 91 | That way, we're not subject to the wrong index problem ; but what happens when the list is empty? We do not have a current question anymore, which means only one thing: our `Game` is finished and we should not even be on the game page! 92 | 93 | Once again, our model allow impossible states! 94 | 95 | 96 | ### Our solution 97 | 98 | With our solution, the current question is always defined and cannot be null. When we answer it, we can take the new current question from the list, and if it is empty, the Elm compiler will force us to handle the case! 99 | 100 | In Elm, **the type system is really powerful**, use it and it will ease your life! 101 | 102 | 103 | ## Instructions 104 | 105 | No more talking, let's practice! Here is what you need to do: 106 | 107 | - Load the questions at page load 108 | - While they are loading, the text "*Loading the questions...*" should be displayed 109 | - If there is an error, the text "*An unknown error occurred while loading the questions.*" should be displayed 110 | - Once the questions are loaded, the first question is displayed 111 | 112 | 113 | 114 | ## Let's start! 115 | 116 | [See the result of your code](./GamePage.elm) (don't forget to refresh to see changes) 117 | 118 | Once the tests are passing, you can go to the [next step](../Step14). -------------------------------------------------------------------------------- /Step13/Solution/GamePage.elm: -------------------------------------------------------------------------------- 1 | module Step13.Solution.GamePage exposing (Category, Game, Model, Msg(..), Question, RemoteData(..), answersDecoder, correctAnswerDecoder, displayAnswer, displayTestsAndView, gamePage, getQuestionsRequest, init, main, questionDecoder, questionsDecoder, questionsUrl, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, a, div, h2, li, text, ul) 5 | import Html.Attributes exposing (class) 6 | import Http exposing (expectJson) 7 | import Json.Decode as Decode 8 | import Result exposing (Result) 9 | import Utils.Utils exposing (styles) 10 | 11 | 12 | questionsUrl : String 13 | questionsUrl = 14 | "https://opentdb.com/api.php?amount=5&type=multiple" 15 | 16 | 17 | main : Program () Model Msg 18 | main = 19 | Browser.element 20 | { init = \_ -> init 21 | , update = update 22 | , view = displayTestsAndView 23 | , subscriptions = \model -> Sub.none 24 | } 25 | 26 | 27 | type alias Question = 28 | { question : String 29 | , correctAnswer : String 30 | , answers : List String 31 | } 32 | 33 | 34 | type alias Model = 35 | { game : RemoteData Game 36 | } 37 | 38 | 39 | type alias Game = 40 | { currentQuestion : Question 41 | , remainingQuestions : List Question 42 | } 43 | 44 | 45 | type Msg 46 | = OnQuestionsFetched (Result Http.Error (List Question)) 47 | 48 | 49 | type alias Category = 50 | { id : Int 51 | , name : String 52 | } 53 | 54 | 55 | type RemoteData a 56 | = Loading 57 | | Loaded a 58 | | OnError 59 | 60 | 61 | init : ( Model, Cmd Msg ) 62 | init = 63 | ( Model Loading, getQuestionsRequest ) 64 | 65 | 66 | getQuestionsRequest : Cmd Msg 67 | getQuestionsRequest = 68 | Http.get 69 | { url = questionsUrl 70 | , expect = expectJson OnQuestionsFetched questionsDecoder 71 | } 72 | 73 | 74 | update : Msg -> Model -> ( Model, Cmd Msg ) 75 | update message model = 76 | case message of 77 | OnQuestionsFetched (Ok (firstQuestion :: remainingQuestions)) -> 78 | let 79 | game = 80 | Game firstQuestion remainingQuestions 81 | in 82 | ( Model (Loaded game), Cmd.none ) 83 | 84 | OnQuestionsFetched _ -> 85 | ( Model OnError, Cmd.none ) 86 | 87 | 88 | view : Model -> Html Msg 89 | view model = 90 | case model.game of 91 | Loading -> 92 | div [] [ text "Loading the questions..." ] 93 | 94 | OnError -> 95 | div [] [ text "An unknown error occurred while loading the questions" ] 96 | 97 | Loaded game -> 98 | gamePage game.currentQuestion 99 | 100 | 101 | gamePage : Question -> Html msg 102 | gamePage question = 103 | div [] 104 | [ h2 [ class "question" ] [ text question.question ] 105 | , ul [ class "answers" ] (List.map displayAnswer question.answers) 106 | ] 107 | 108 | 109 | displayAnswer : String -> Html msg 110 | displayAnswer answer = 111 | li [] [ a [ class "btn btn-primary" ] [ text answer ] ] 112 | 113 | 114 | questionsDecoder : Decode.Decoder (List Question) 115 | questionsDecoder = 116 | Decode.field "results" (Decode.list questionDecoder) 117 | 118 | 119 | questionDecoder : Decode.Decoder Question 120 | questionDecoder = 121 | Decode.map3 122 | Question 123 | (Decode.field "question" Decode.string) 124 | correctAnswerDecoder 125 | answersDecoder 126 | 127 | 128 | correctAnswerDecoder : Decode.Decoder String 129 | correctAnswerDecoder = 130 | Decode.field "correct_answer" Decode.string 131 | 132 | 133 | answersDecoder : Decode.Decoder (List String) 134 | answersDecoder = 135 | Decode.map2 136 | (\correctAnswer incorrectAnswers -> correctAnswer :: incorrectAnswers) 137 | correctAnswerDecoder 138 | (Decode.field "incorrect_answers" (Decode.list Decode.string)) 139 | 140 | 141 | 142 | {- Or in a more concise way: 143 | 144 | answersDecoder : Decode.Decoder (List String) 145 | answersDecoder = 146 | Decode.map2 147 | (::) 148 | correctAnswerDecoder 149 | (Decode.field "incorrect_answers" (Decode.list Decode.string)) 150 | -} 151 | ------------------------------------------------------------------------------------------------------ 152 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 153 | ------------------------------------------------------------------------------------------------------ 154 | 155 | 156 | displayTestsAndView : Model -> Html Msg 157 | displayTestsAndView model = 158 | div [] 159 | [ styles 160 | , div [ class "jumbotron" ] [ view model ] 161 | ] 162 | -------------------------------------------------------------------------------- /Step13/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step13.Tests.Tests exposing (main) 2 | 3 | import Expect 4 | import Fuzz 5 | import Html exposing (Html, div) 6 | import Http exposing (Error(..)) 7 | import Random 8 | import Step13.GamePage as GamePage exposing (Game, Model, Msg(..), Question, RemoteData(..)) 9 | import Test exposing (Test, concat, fuzz, test) 10 | import Test.Html.Selector as Selector 11 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 12 | import TestContext exposing (SimulatedEffect(..), TestContext, createWithSimulatedEffects, expectModel, expectViewHas, simulateLastEffect) 13 | import Utils.Utils exposing (testStyles) 14 | 15 | 16 | questionsUrl : String 17 | questionsUrl = 18 | "https://opentdb.com/api.php?amount=5&type=multiple" 19 | 20 | 21 | gamePageProgram : TestContext Msg Model (Cmd Msg) 22 | gamePageProgram = 23 | createWithSimulatedEffects 24 | { init = GamePage.init 25 | , update = GamePage.update 26 | , view = GamePage.view 27 | , deconstructEffect = \_ -> [ HttpRequest { method = "GET", url = questionsUrl } ] 28 | } 29 | 30 | 31 | main : Html a 32 | main = 33 | div [] 34 | [ testStyles 35 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) testsSuite 36 | ] 37 | 38 | 39 | testsSuite : Test 40 | testsSuite = 41 | concat 42 | [ theInitMethodShouldFetchQuestions 43 | , theInitModelShouldBeLoading 44 | , whenTheCategoriesAreLoadingAMessageShouldSaySo 45 | , whenInitRequestFailTheCategoriesShouldBeOnError 46 | , whenInitRequestFailThereShouldBeAnError 47 | , whenInitRequestCompletesTheModelShouldBeUpdated 48 | , whenInitRequestCompletesTheResultShouldBeDisplayed 49 | ] 50 | 51 | 52 | theInitMethodShouldFetchQuestions : Test 53 | theInitMethodShouldFetchQuestions = 54 | test "The init method should return a `Cmd` (ideally to fetch questions, but this is not covered by this test)." <| 55 | \() -> 56 | Expect.notEqual Cmd.none (Tuple.second GamePage.init) 57 | 58 | 59 | theInitModelShouldBeLoading : Test 60 | theInitModelShouldBeLoading = 61 | test "The init model should indicates that the questions are loading" <| 62 | \() -> 63 | Expect.equal (Model Loading) (Tuple.first GamePage.init) 64 | 65 | 66 | whenTheCategoriesAreLoadingAMessageShouldSaySo : Test 67 | whenTheCategoriesAreLoadingAMessageShouldSaySo = 68 | test "When the request is loading, the following message should be displayed: \"Loading the questions...\"" <| 69 | \() -> 70 | gamePageProgram 71 | |> expectViewHas [ Selector.containing [ Selector.text "Loading the questions..." ] ] 72 | 73 | 74 | whenInitRequestFailTheCategoriesShouldBeOnError : Test 75 | whenInitRequestFailTheCategoriesShouldBeOnError = 76 | test "When the request fails, the model should keep track of that" <| 77 | \() -> 78 | gamePageProgram 79 | |> simulateLastEffect (\_ -> Ok [ Err NetworkError |> OnQuestionsFetched ]) 80 | |> expectModel (Expect.equal (Model OnError)) 81 | 82 | 83 | whenInitRequestFailThereShouldBeAnError : Test 84 | whenInitRequestFailThereShouldBeAnError = 85 | test "When the request fails, the following error message should be displayed: \"An unknown error occurred while loading the questions\"" <| 86 | \() -> 87 | gamePageProgram 88 | |> simulateLastEffect (\_ -> Ok [ Err NetworkError |> OnQuestionsFetched ]) 89 | |> expectViewHas [ Selector.containing [ Selector.text "An unknown error occurred while loading the questions" ] ] 90 | 91 | 92 | whenInitRequestCompletesTheModelShouldBeUpdated : Test 93 | whenInitRequestCompletesTheModelShouldBeUpdated = 94 | fuzz randomQuestionsFuzz "When the request completes, the model should store the questions returned" <| 95 | \randomQuestions -> 96 | case randomQuestions of 97 | [] -> 98 | Expect.pass 99 | 100 | firstQuestion :: remainingQuestions -> 101 | let 102 | expectedModel = 103 | Model (Loaded (Game firstQuestion remainingQuestions)) 104 | in 105 | gamePageProgram 106 | |> simulateLastEffect (\_ -> Ok [ Ok randomQuestions |> OnQuestionsFetched ]) 107 | |> expectModel (Expect.equal expectedModel) 108 | 109 | 110 | whenInitRequestCompletesTheResultShouldBeDisplayed : Test 111 | whenInitRequestCompletesTheResultShouldBeDisplayed = 112 | fuzz randomQuestionsFuzz "When the request completes, the first question should be displayed" <| 113 | \randomQuestions -> 114 | case randomQuestions of 115 | [] -> 116 | Expect.pass 117 | 118 | firstQuestion :: _ -> 119 | gamePageProgram 120 | |> simulateLastEffect (\_ -> Ok [ Ok randomQuestions |> OnQuestionsFetched ]) 121 | |> expectViewHas [ Selector.containing [ Selector.text firstQuestion.question ] ] 122 | 123 | 124 | randomQuestionsFuzz : Fuzz.Fuzzer (List Question) 125 | randomQuestionsFuzz = 126 | Fuzz.map5 127 | (\question answer1 answer2 answer3 answer4 -> 128 | Question question answer1 [ answer1, answer2, answer3, answer4 ] 129 | ) 130 | Fuzz.string 131 | Fuzz.string 132 | Fuzz.string 133 | Fuzz.string 134 | Fuzz.string 135 | |> Fuzz.list 136 | -------------------------------------------------------------------------------- /Step14/GamePage.elm: -------------------------------------------------------------------------------- 1 | module Step14.GamePage exposing (Category, Game, Model, Msg(..), Question, RemoteData(..), answersDecoder, correctAnswerDecoder, displayAnswer, gamePage, getQuestionsRequest, init, main, questionDecoder, questionsDecoder, questionsUrl, update, view) 2 | 3 | import Browser 4 | import Html exposing (Html, a, div, h2, li, text, ul) 5 | import Html.Attributes exposing (class) 6 | import Http exposing (expectJson) 7 | import Json.Decode as Decode 8 | import Result exposing (Result) 9 | 10 | 11 | questionsUrl : String 12 | questionsUrl = 13 | "https://opentdb.com/api.php?amount=5&type=multiple" 14 | 15 | 16 | main : Program () Model Msg 17 | main = 18 | Browser.element 19 | { init = \_ -> init 20 | , update = update 21 | , view = view 22 | , subscriptions = always Sub.none 23 | } 24 | 25 | 26 | type alias Question = 27 | { question : String 28 | , correctAnswer : String 29 | , answers : List String 30 | } 31 | 32 | 33 | type alias Model = 34 | { game : RemoteData Game 35 | } 36 | 37 | 38 | type alias Game = 39 | { currentQuestion : Question 40 | , remainingQuestions : List Question 41 | } 42 | 43 | 44 | type Msg 45 | = OnQuestionsFetched (Result Http.Error (List Question)) 46 | 47 | 48 | type alias Category = 49 | { id : Int 50 | , name : String 51 | } 52 | 53 | 54 | type RemoteData a 55 | = Loading 56 | | Loaded a 57 | | OnError 58 | 59 | 60 | init : ( Model, Cmd Msg ) 61 | init = 62 | ( Model Loading, getQuestionsRequest ) 63 | 64 | 65 | getQuestionsRequest : Cmd Msg 66 | getQuestionsRequest = 67 | Http.get 68 | { url = questionsUrl 69 | , expect = expectJson OnQuestionsFetched questionsDecoder 70 | } 71 | 72 | 73 | update : Msg -> Model -> ( Model, Cmd Msg ) 74 | update message model = 75 | case message of 76 | OnQuestionsFetched (Ok (firstQuestion :: remainingQuestions)) -> 77 | let 78 | game = 79 | Game firstQuestion remainingQuestions 80 | in 81 | ( Model (Loaded game), Cmd.none ) 82 | 83 | OnQuestionsFetched _ -> 84 | ( Model OnError, Cmd.none ) 85 | 86 | 87 | view : Model -> Html Msg 88 | view model = 89 | case model.game of 90 | Loading -> 91 | div [] [ text "Loading the questions..." ] 92 | 93 | OnError -> 94 | div [] [ text "An unknown error occurred while loading the questions" ] 95 | 96 | Loaded game -> 97 | gamePage game.currentQuestion 98 | 99 | 100 | gamePage : Question -> Html msg 101 | gamePage question = 102 | div [] 103 | [ h2 [ class "question" ] [ text question.question ] 104 | , ul [ class "answers" ] (List.map displayAnswer question.answers) 105 | ] 106 | 107 | 108 | displayAnswer : String -> Html msg 109 | displayAnswer answer = 110 | li [] [ a [ class "btn btn-primary" ] [ text answer ] ] 111 | 112 | 113 | questionsDecoder : Decode.Decoder (List Question) 114 | questionsDecoder = 115 | Decode.field "results" (Decode.list questionDecoder) 116 | 117 | 118 | questionDecoder : Decode.Decoder Question 119 | questionDecoder = 120 | Decode.map3 121 | Question 122 | (Decode.field "question" Decode.string) 123 | correctAnswerDecoder 124 | answersDecoder 125 | 126 | 127 | correctAnswerDecoder : Decode.Decoder String 128 | correctAnswerDecoder = 129 | Decode.field "correct_answer" Decode.string 130 | 131 | 132 | answersDecoder : Decode.Decoder (List String) 133 | answersDecoder = 134 | Decode.map2 135 | (::) 136 | correctAnswerDecoder 137 | (Decode.field "incorrect_answers" (Decode.list Decode.string)) 138 | -------------------------------------------------------------------------------- /Step14/Main.elm: -------------------------------------------------------------------------------- 1 | module Step14.Main exposing (Category, Model, Msg(..), Page(..), RemoteData(..), categoriesDecoder, displayCategoriesList, displayCategoriesPage, displayCategory, displayHomePage, displayView, getCategoriesRequest, getCategoriesUrl, init, main, update, view) 2 | 3 | import Browser exposing (Document, UrlRequest(..)) 4 | import Browser.Navigation as Navigation exposing (Key) 5 | import Html exposing (Html, a, div, h1, li, p, text, ul) 6 | import Html.Attributes exposing (class, href) 7 | import Http exposing (expectJson) 8 | import Json.Decode as Decode 9 | import Result exposing (Result) 10 | import Url exposing (Url) 11 | import Url.Parser as Parser exposing (()) 12 | import Utils.Utils exposing (styles, testsIframe) 13 | 14 | 15 | main : Program () Model Msg 16 | main = 17 | Browser.application 18 | { init = init 19 | , update = update 20 | , view = displayView 21 | , subscriptions = always Sub.none 22 | , onUrlRequest = OnUrlRequest 23 | , onUrlChange = OnUrlChange 24 | } 25 | 26 | 27 | type Msg 28 | = OnCategoriesFetched (Result Http.Error (List Category)) 29 | | OnUrlRequest UrlRequest 30 | | OnUrlChange Url 31 | 32 | 33 | type Page 34 | = HomePage 35 | | CategoriesPage (RemoteData (List Category)) 36 | | ResultPage Int 37 | 38 | 39 | type Route 40 | = HomeRoute 41 | | CategoriesRoute 42 | | ResultRoute Int 43 | 44 | 45 | type alias Model = 46 | { key : Key 47 | , page : Page 48 | } 49 | 50 | 51 | type alias Category = 52 | { id : Int, name : String } 53 | 54 | 55 | type RemoteData a 56 | = Loading 57 | | Loaded a 58 | | OnError 59 | 60 | 61 | routeParser : Parser.Parser (Route -> Route) Route 62 | routeParser = 63 | Parser.oneOf 64 | [ Parser.map HomeRoute Parser.top 65 | , Parser.map CategoriesRoute (Parser.s "categories") 66 | , Parser.map ResultRoute (Parser.s "result" Parser.int) 67 | ] 68 | 69 | 70 | parseUrlToPageAndCommand : Url -> ( Page, Cmd Msg ) 71 | parseUrlToPageAndCommand url = 72 | let 73 | routeMaybe : Maybe Route 74 | routeMaybe = 75 | Parser.parse routeParser { url | path = url.fragment |> Maybe.withDefault "", fragment = Nothing } 76 | in 77 | case routeMaybe of 78 | Just CategoriesRoute -> 79 | ( CategoriesPage Loading, getCategoriesRequest ) 80 | 81 | Just (ResultRoute score) -> 82 | ( ResultPage score, Cmd.none ) 83 | 84 | Just HomeRoute -> 85 | ( HomePage, Cmd.none ) 86 | 87 | Nothing -> 88 | ( HomePage, Cmd.none ) 89 | 90 | 91 | init : () -> Url -> Key -> ( Model, Cmd Msg ) 92 | init _ url key = 93 | let 94 | ( page, cmd ) = 95 | parseUrlToPageAndCommand url 96 | in 97 | ( Model key page, cmd ) 98 | 99 | 100 | update : Msg -> Model -> ( Model, Cmd Msg ) 101 | update msg model = 102 | case msg of 103 | OnCategoriesFetched (Ok categories) -> 104 | case model.page of 105 | CategoriesPage _ -> 106 | ( { model | page = CategoriesPage (Loaded categories) }, Cmd.none ) 107 | 108 | _ -> 109 | ( model, Cmd.none ) 110 | 111 | OnCategoriesFetched (Err err) -> 112 | case model.page of 113 | CategoriesPage _ -> 114 | ( { model | page = CategoriesPage OnError }, Cmd.none ) 115 | 116 | _ -> 117 | ( model, Cmd.none ) 118 | 119 | OnUrlRequest urlRequest -> 120 | case urlRequest of 121 | External url -> 122 | ( model, Navigation.load url ) 123 | 124 | Internal url -> 125 | ( model, Navigation.pushUrl model.key (Url.toString url) ) 126 | 127 | OnUrlChange url -> 128 | let 129 | ( page, cmd ) = 130 | parseUrlToPageAndCommand url 131 | in 132 | ( { model | page = page }, cmd ) 133 | 134 | 135 | getCategoriesUrl : String 136 | getCategoriesUrl = 137 | "https://opentdb.com/api_category.php" 138 | 139 | 140 | categoriesDecoder : Decode.Decoder (List Category) 141 | categoriesDecoder = 142 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 143 | |> Decode.list 144 | |> Decode.field "trivia_categories" 145 | 146 | 147 | getCategoriesRequest : Cmd Msg 148 | getCategoriesRequest = 149 | Http.get 150 | { url = getCategoriesUrl 151 | , expect = expectJson OnCategoriesFetched categoriesDecoder 152 | } 153 | 154 | 155 | view : Model -> Html Msg 156 | view model = 157 | div [] 158 | [ case model.page of 159 | HomePage -> 160 | displayHomePage 161 | 162 | CategoriesPage categoriesModel -> 163 | displayCategoriesPage categoriesModel 164 | 165 | ResultPage score -> 166 | displayResultPage score 167 | ] 168 | 169 | 170 | displayHomePage : Html Msg 171 | displayHomePage = 172 | div [ class "gameOptions" ] 173 | [ h1 [] [ text "Quiz Game" ] 174 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 175 | , a [ class "btn btn-primary", href "#game" ] [ text "Play random questions" ] 176 | ] 177 | 178 | 179 | displayResultPage : Int -> Html Msg 180 | displayResultPage score = 181 | div [ class "score" ] 182 | [ h1 [] [ text ("Your score: " ++ String.fromInt score ++ " / 5") ] 183 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 184 | , p [] [ text (displayComment score) ] 185 | ] 186 | 187 | 188 | displayComment : Int -> String 189 | displayComment score = 190 | if score <= 3 then 191 | "Keep going, I'm sure you can do better!" 192 | 193 | else 194 | "Congrats, this is really good!" 195 | 196 | 197 | displayCategoriesPage : RemoteData (List Category) -> Html Msg 198 | displayCategoriesPage categories = 199 | div [] 200 | [ h1 [] [ text "Play within a given category" ] 201 | , displayCategoriesList categories 202 | ] 203 | 204 | 205 | displayCategoriesList : RemoteData (List Category) -> Html Msg 206 | displayCategoriesList categoriesRemote = 207 | case categoriesRemote of 208 | Loaded categories -> 209 | List.map displayCategory categories 210 | |> ul [ class "categories" ] 211 | 212 | OnError -> 213 | text "An error occurred while fetching categories" 214 | 215 | Loading -> 216 | text "Categories are loading..." 217 | 218 | 219 | displayCategory : Category -> Html Msg 220 | displayCategory category = 221 | let 222 | path = 223 | "#game/category/" ++ String.fromInt category.id 224 | in 225 | li [] 226 | [ a [ class "btn btn-primary", href path ] [ text category.name ] 227 | ] 228 | 229 | 230 | 231 | ------------------------------------------------------------------------------------------------------ 232 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 233 | ------------------------------------------------------------------------------------------------------ 234 | 235 | 236 | displayView : Model -> Document Msg 237 | displayView model = 238 | Document 239 | "Step 14" 240 | [ styles 241 | , view model 242 | , testsIframe 243 | ] 244 | -------------------------------------------------------------------------------- /Step14/README.md: -------------------------------------------------------------------------------- 1 | # Step 14: Integrate the Game page 2 | 3 | ## Goal 4 | 5 | The Game page only display the first question for now, but before going further into the game logic, let's add it to what we've done before! 6 | 7 | In this step, we are gonna integrate this new page inside our navigation. 8 | 9 | To ease your life, the solution of the previous step has been copied inside a *GamePage.elm* file next to the main file. 10 | 11 | Be careful where you will store the `Game` object inside your model, it should only be available on the game page! 12 | 13 | **/!\ WARNING /!\** There are two Elm files ; you should only modify the `Main.elm` file. However you can copy/paste or import elements from the other file. 14 | 15 | ## Let's start! 16 | 17 | [See the result of your code](./Main.elm) (don't forget to refresh to see changes) 18 | 19 | Once the tests are passing, you can go to the [next step](../Step15). 20 | -------------------------------------------------------------------------------- /Step14/Solution/Main.elm: -------------------------------------------------------------------------------- 1 | module Step14.Solution.Main exposing (Category, Model, Msg(..), Page(..), RemoteData(..), categoriesDecoder, displayCategoriesList, displayCategoriesPage, displayCategory, displayHomePage, displayView, getCategoriesRequest, getCategoriesUrl, init, main, update, view) 2 | 3 | import Browser exposing (Document, UrlRequest(..)) 4 | import Browser.Navigation as Navigation exposing (Key) 5 | import Html exposing (Html, a, div, h1, li, p, text, ul) 6 | import Html.Attributes exposing (class, href) 7 | import Http exposing (expectJson) 8 | import Json.Decode as Decode 9 | import Result exposing (Result) 10 | import Step14.GamePage exposing (Game, Question, gamePage, questionsDecoder, questionsUrl) 11 | import Url exposing (Url) 12 | import Url.Parser as Parser exposing (()) 13 | import Utils.Utils exposing (styles) 14 | 15 | 16 | main : Program () Model Msg 17 | main = 18 | Browser.application 19 | { init = init 20 | , update = update 21 | , view = displayView 22 | , subscriptions = always Sub.none 23 | , onUrlRequest = OnUrlRequest 24 | , onUrlChange = OnUrlChange 25 | } 26 | 27 | 28 | type Msg 29 | = OnCategoriesFetched (Result Http.Error (List Category)) 30 | | OnUrlRequest UrlRequest 31 | | OnUrlChange Url 32 | | OnQuestionsFetched (Result Http.Error (List Question)) 33 | 34 | 35 | type Page 36 | = HomePage 37 | | CategoriesPage (RemoteData (List Category)) 38 | | ResultPage Int 39 | | GamePage (RemoteData Game) 40 | 41 | 42 | type Route 43 | = HomeRoute 44 | | CategoriesRoute 45 | | ResultRoute Int 46 | | GameRoute 47 | 48 | 49 | type alias Model = 50 | { key : Key 51 | , page : Page 52 | } 53 | 54 | 55 | type alias Category = 56 | { id : Int, name : String } 57 | 58 | 59 | type RemoteData a 60 | = Loading 61 | | Loaded a 62 | | OnError 63 | 64 | 65 | routeParser : Parser.Parser (Route -> Route) Route 66 | routeParser = 67 | Parser.oneOf 68 | [ Parser.map HomeRoute Parser.top 69 | , Parser.map CategoriesRoute (Parser.s "categories") 70 | , Parser.map ResultRoute (Parser.s "result" Parser.int) 71 | , Parser.map GameRoute (Parser.s "game") 72 | ] 73 | 74 | 75 | parseUrlToPageAndCommand : Url -> ( Page, Cmd Msg ) 76 | parseUrlToPageAndCommand url = 77 | let 78 | routeMaybe : Maybe Route 79 | routeMaybe = 80 | Parser.parse routeParser { url | path = url.fragment |> Maybe.withDefault "", fragment = Nothing } 81 | in 82 | case routeMaybe of 83 | Just CategoriesRoute -> 84 | ( CategoriesPage Loading, getCategoriesRequest ) 85 | 86 | Just (ResultRoute score) -> 87 | ( ResultPage score, Cmd.none ) 88 | 89 | Just HomeRoute -> 90 | ( HomePage, Cmd.none ) 91 | 92 | Just GameRoute -> 93 | ( GamePage Loading, getQuestionsRequest ) 94 | 95 | Nothing -> 96 | ( HomePage, Cmd.none ) 97 | 98 | 99 | getQuestionsRequest : Cmd Msg 100 | getQuestionsRequest = 101 | Http.get 102 | { url = questionsUrl 103 | , expect = expectJson OnQuestionsFetched questionsDecoder 104 | } 105 | 106 | 107 | init : () -> Url -> Key -> ( Model, Cmd Msg ) 108 | init _ url key = 109 | let 110 | ( page, cmd ) = 111 | parseUrlToPageAndCommand url 112 | in 113 | ( Model key page, cmd ) 114 | 115 | 116 | update : Msg -> Model -> ( Model, Cmd Msg ) 117 | update msg model = 118 | case msg of 119 | OnCategoriesFetched (Ok categories) -> 120 | case model.page of 121 | CategoriesPage _ -> 122 | ( { model | page = CategoriesPage (Loaded categories) }, Cmd.none ) 123 | 124 | _ -> 125 | ( model, Cmd.none ) 126 | 127 | OnCategoriesFetched (Err err) -> 128 | case model.page of 129 | CategoriesPage _ -> 130 | ( { model | page = CategoriesPage OnError }, Cmd.none ) 131 | 132 | _ -> 133 | ( model, Cmd.none ) 134 | 135 | OnUrlRequest urlRequest -> 136 | case urlRequest of 137 | External url -> 138 | ( model, Navigation.load url ) 139 | 140 | Internal url -> 141 | ( model, Navigation.pushUrl model.key (Url.toString url) ) 142 | 143 | OnUrlChange url -> 144 | let 145 | ( page, cmd ) = 146 | parseUrlToPageAndCommand url 147 | in 148 | ( { model | page = page }, cmd ) 149 | 150 | OnQuestionsFetched (Ok (firstQuestion :: remainingQuestions)) -> 151 | let 152 | game = 153 | Game firstQuestion remainingQuestions 154 | in 155 | ( { model | page = GamePage (Loaded game) }, Cmd.none ) 156 | 157 | OnQuestionsFetched _ -> 158 | ( { model | page = GamePage OnError }, Cmd.none ) 159 | 160 | 161 | getCategoriesUrl : String 162 | getCategoriesUrl = 163 | "https://opentdb.com/api_category.php" 164 | 165 | 166 | categoriesDecoder : Decode.Decoder (List Category) 167 | categoriesDecoder = 168 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 169 | |> Decode.list 170 | |> Decode.field "trivia_categories" 171 | 172 | 173 | getCategoriesRequest : Cmd Msg 174 | getCategoriesRequest = 175 | Http.get 176 | { url = getCategoriesUrl 177 | , expect = expectJson OnCategoriesFetched categoriesDecoder 178 | } 179 | 180 | 181 | view : Model -> Html Msg 182 | view model = 183 | div [] 184 | [ case model.page of 185 | HomePage -> 186 | displayHomePage 187 | 188 | CategoriesPage categoriesModel -> 189 | displayCategoriesPage categoriesModel 190 | 191 | ResultPage score -> 192 | displayResultPage score 193 | 194 | GamePage gameRemoteData -> 195 | case gameRemoteData of 196 | Loading -> 197 | div [] [ text "Loading the questions..." ] 198 | 199 | OnError -> 200 | div [] [ text "An unknown error occurred while loading the questions" ] 201 | 202 | Loaded game -> 203 | gamePage game.currentQuestion 204 | ] 205 | 206 | 207 | displayHomePage : Html Msg 208 | displayHomePage = 209 | div [ class "gameOptions" ] 210 | [ h1 [] [ text "Quiz Game" ] 211 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 212 | , a [ class "btn btn-primary", href "#game" ] [ text "Play random questions" ] 213 | ] 214 | 215 | 216 | displayResultPage : Int -> Html Msg 217 | displayResultPage score = 218 | div [ class "score" ] 219 | [ h1 [] [ text ("Your score: " ++ String.fromInt score ++ " / 5") ] 220 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 221 | , p [] [ text (displayComment score) ] 222 | ] 223 | 224 | 225 | displayComment : Int -> String 226 | displayComment score = 227 | if score <= 3 then 228 | "Keep going, I'm sure you can do better!" 229 | 230 | else 231 | "Congrats, this is really good!" 232 | 233 | 234 | displayCategoriesPage : RemoteData (List Category) -> Html Msg 235 | displayCategoriesPage categories = 236 | div [] 237 | [ h1 [] [ text "Play within a given category" ] 238 | , displayCategoriesList categories 239 | ] 240 | 241 | 242 | displayCategoriesList : RemoteData (List Category) -> Html Msg 243 | displayCategoriesList categoriesRemote = 244 | case categoriesRemote of 245 | Loaded categories -> 246 | List.map displayCategory categories 247 | |> ul [ class "categories" ] 248 | 249 | OnError -> 250 | text "An error occurred while fetching categories" 251 | 252 | Loading -> 253 | text "Categories are loading..." 254 | 255 | 256 | displayCategory : Category -> Html Msg 257 | displayCategory category = 258 | let 259 | path = 260 | "#game/category/" ++ String.fromInt category.id 261 | in 262 | li [] 263 | [ a [ class "btn btn-primary", href path ] [ text category.name ] 264 | ] 265 | 266 | 267 | 268 | ------------------------------------------------------------------------------------------------------ 269 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 270 | ------------------------------------------------------------------------------------------------------ 271 | 272 | 273 | displayView : Model -> Document Msg 274 | displayView model = 275 | Document 276 | "Step 14" 277 | [ styles 278 | , view model 279 | ] 280 | -------------------------------------------------------------------------------- /Step14/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step14.Tests.Tests exposing (main) 2 | 3 | import Browser exposing (Document) 4 | import Browser.Navigation exposing (Key) 5 | import Expect exposing (Expectation) 6 | import Fuzz exposing (intRange) 7 | import Random 8 | import Step14.Main exposing (Msg(..), init, update, view) 9 | import Test exposing (Test, concat, fuzz, test) 10 | import Test.Html.Query as Query 11 | import Test.Html.Selector exposing (text) 12 | import Test.Runner.Html exposing (defaultConfig, hidePassedTests, viewResults) 13 | import Url exposing (Protocol(..), Url) 14 | import Utils.Utils exposing (testStyles) 15 | 16 | 17 | main : Program () Key () 18 | main = 19 | let 20 | testsView key = 21 | Document 22 | "Tests for step 14" 23 | [ testStyles 24 | , viewResults (Random.initialSeed 1000 |> defaultConfig |> hidePassedTests) (testsSuite key) 25 | ] 26 | 27 | init _ _ key = 28 | ( key, Cmd.none ) 29 | 30 | update _ key = 31 | ( key, Cmd.none ) 32 | in 33 | Browser.application 34 | { init = init 35 | , update = update 36 | , view = testsView 37 | , subscriptions = always Sub.none 38 | , onUrlRequest = always () 39 | , onUrlChange = always () 40 | } 41 | 42 | 43 | fakeHomeUrl : Url 44 | fakeHomeUrl = 45 | { protocol = Http 46 | , host = "localhost" 47 | , port_ = Just 80 48 | , path = "/" 49 | , query = Nothing 50 | , fragment = Nothing 51 | } 52 | 53 | 54 | fakeCategoriesUrl : Url 55 | fakeCategoriesUrl = 56 | { fakeHomeUrl | fragment = Just "categories" } 57 | 58 | 59 | fakeResultUrl : Int -> Url 60 | fakeResultUrl score = 61 | { fakeHomeUrl | fragment = Just ("result/" ++ String.fromInt score) } 62 | 63 | 64 | fakeGameUrl : Url 65 | fakeGameUrl = 66 | { fakeHomeUrl | fragment = Just "game" } 67 | 68 | 69 | testsSuite : Key -> Test 70 | testsSuite key = 71 | concat 72 | [ whenGoingToGamePathGamePageShouldBeDisplayed key 73 | , whenGoingToGamePathQuestionsShouldBeFetched key 74 | , atBasePathHomepageShouldBeDisplayed key 75 | , atLoadingCategoriesShouldNotBeFetched key 76 | , categoriesShouldBeLoaded key 77 | , whenGoingToCategoriesPathCategoriesShouldBeDisplayed key 78 | , whenGoingToResultPathResultShouldBeDisplayed key 79 | , whenGoingToResultPathResultShouldBeDisplayedWithProperScore key 80 | ] 81 | 82 | 83 | atBasePathHomepageShouldBeDisplayed : Key -> Test 84 | atBasePathHomepageShouldBeDisplayed key = 85 | test "When loading the page with home URL, the homepage should appear" <| 86 | \() -> 87 | let 88 | initialModel = 89 | init () fakeHomeUrl key |> Tuple.first 90 | in 91 | view initialModel 92 | |> Query.fromHtml 93 | |> Expect.all 94 | [ Query.has [ text "Quiz Game" ] 95 | , Query.has [ text "Play random questions" ] 96 | , Query.has [ text "Play from a category" ] 97 | ] 98 | 99 | 100 | atLoadingCategoriesShouldNotBeFetched : Key -> Test 101 | atLoadingCategoriesShouldNotBeFetched key = 102 | test "When loading the page with home URL, nothing should be fetched" <| 103 | \() -> 104 | Expect.equal Cmd.none (init () fakeHomeUrl key |> Tuple.second) 105 | 106 | 107 | categoriesShouldBeLoaded : Key -> Test 108 | categoriesShouldBeLoaded key = 109 | test "When loading the page with categories URL, categories should be fetched" <| 110 | \() -> 111 | Expect.notEqual Cmd.none (init () fakeCategoriesUrl key |> Tuple.second) 112 | 113 | 114 | whenGoingToCategoriesPathCategoriesShouldBeDisplayed : Key -> Test 115 | whenGoingToCategoriesPathCategoriesShouldBeDisplayed key = 116 | test "When we go on the categories link (/#categories), the categories page should be displayed" <| 117 | \() -> 118 | let 119 | initialModel = 120 | init () fakeHomeUrl key 121 | |> Tuple.first 122 | 123 | updatedView = 124 | update (OnUrlChange fakeCategoriesUrl) initialModel 125 | |> Tuple.first 126 | |> view 127 | in 128 | updatedView 129 | |> Query.fromHtml 130 | |> Expect.all 131 | [ Query.has [ text "Categories are loading" ] 132 | , Query.hasNot [ text "Play from a category" ] 133 | ] 134 | 135 | 136 | whenGoingToResultPathResultShouldBeDisplayed : Key -> Test 137 | whenGoingToResultPathResultShouldBeDisplayed key = 138 | test "When we go to the path \"#result/{score}\", the result page should be displayed" <| 139 | \() -> 140 | let 141 | initialModel = 142 | init () fakeHomeUrl key 143 | |> Tuple.first 144 | 145 | updatedView = 146 | update (OnUrlChange (fakeResultUrl 5)) initialModel 147 | |> Tuple.first 148 | |> view 149 | in 150 | updatedView 151 | |> Query.fromHtml 152 | |> Expect.all 153 | [ Query.has [ text "Your score" ] 154 | , Query.hasNot [ text "Play from a category" ] 155 | ] 156 | 157 | 158 | whenGoingToResultPathResultShouldBeDisplayedWithProperScore : Key -> Test 159 | whenGoingToResultPathResultShouldBeDisplayedWithProperScore key = 160 | fuzz (intRange 0 5) "When we go to the path \"#result/{score}\", the result page should be displayed with the proper result" <| 161 | \score -> 162 | let 163 | initialModel = 164 | init () fakeHomeUrl key 165 | |> Tuple.first 166 | 167 | updatedView = 168 | update (OnUrlChange (fakeResultUrl score)) initialModel 169 | |> Tuple.first 170 | |> view 171 | in 172 | updatedView 173 | |> Query.fromHtml 174 | |> Query.has [ String.fromInt score ++ " / 5" |> text ] 175 | 176 | 177 | whenGoingToGamePathGamePageShouldBeDisplayed : Key -> Test 178 | whenGoingToGamePathGamePageShouldBeDisplayed key = 179 | test "When we go to the path \"#game\", the game page should be displayed" <| 180 | \() -> 181 | let 182 | initialModel = 183 | init () fakeHomeUrl key 184 | |> Tuple.first 185 | 186 | updatedView = 187 | update (OnUrlChange fakeGameUrl) initialModel 188 | |> Tuple.first 189 | |> view 190 | in 191 | updatedView 192 | |> Query.fromHtml 193 | |> Query.has [ text "Loading the questions" ] 194 | 195 | 196 | whenGoingToGamePathQuestionsShouldBeFetched : Key -> Test 197 | whenGoingToGamePathQuestionsShouldBeFetched key = 198 | test "When we go to the path \"#game\", questions should be fetched" <| 199 | \() -> 200 | let 201 | initialModel = 202 | init () fakeHomeUrl key 203 | |> Tuple.first 204 | 205 | updatedCommand = 206 | update (OnUrlChange fakeGameUrl) initialModel 207 | |> Tuple.second 208 | in 209 | Expect.notEqual Cmd.none updatedCommand 210 | -------------------------------------------------------------------------------- /Step15/Main.elm: -------------------------------------------------------------------------------- 1 | module Step15.Main exposing (Category, Model, Msg(..), Page(..), RemoteData(..), Route(..), categoriesDecoder, displayCategoriesList, displayCategoriesPage, displayCategory, displayComment, displayHomePage, displayResultPage, getCategoriesRequest, getCategoriesUrl, getQuestionsRequest, init, main, parseUrlToPageAndCommand, routeParser, update, view) 2 | 3 | import Browser exposing (Document, UrlRequest) 4 | import Browser.Navigation exposing (Key) 5 | import Html exposing (..) 6 | import Html.Attributes exposing (..) 7 | import Http exposing (expectJson) 8 | import Json.Decode as Decode 9 | import Url exposing (Url) 10 | import Url.Parser as Parser exposing (()) 11 | import Utils.Utils exposing (styles, testsIframe) 12 | 13 | 14 | questionsUrl : String 15 | questionsUrl = 16 | "https://opentdb.com/api.php?amount=5&type=multiple" 17 | 18 | 19 | main : Program () Model Msg 20 | main = 21 | Browser.application 22 | { init = init 23 | , update = update 24 | , view = displayView 25 | , subscriptions = always Sub.none 26 | , onUrlRequest = OnUrlRequest 27 | , onUrlChange = OnUrlChange 28 | } 29 | 30 | 31 | type Msg 32 | = OnCategoriesFetched (Result Http.Error (List Category)) 33 | | OnUrlRequest UrlRequest 34 | | OnUrlChange Url 35 | | OnQuestionsFetched (Result Http.Error (List Question)) 36 | 37 | 38 | type Page 39 | = HomePage 40 | | CategoriesPage (RemoteData (List Category)) 41 | | ResultPage Int 42 | | GamePage (RemoteData Game) 43 | 44 | 45 | type Route 46 | = HomeRoute 47 | | CategoriesRoute 48 | | ResultRoute Int 49 | | GameRoute 50 | 51 | 52 | type alias Model = 53 | { key : Key 54 | , page : Page 55 | } 56 | 57 | 58 | type alias Category = 59 | { id : Int, name : String } 60 | 61 | 62 | type alias Question = 63 | { question : String 64 | , correctAnswer : String 65 | , answers : List String 66 | } 67 | 68 | 69 | type alias Game = 70 | { currentQuestion : Question 71 | , remainingQuestions : List Question 72 | } 73 | 74 | 75 | type RemoteData a 76 | = Loading 77 | | Loaded a 78 | | OnError 79 | 80 | 81 | routeParser : Parser.Parser (Route -> Route) Route 82 | routeParser = 83 | Parser.oneOf 84 | [ Parser.map HomeRoute Parser.top 85 | , Parser.map CategoriesRoute (Parser.s "categories") 86 | , Parser.map ResultRoute (Parser.s "result" Parser.int) 87 | , Parser.map GameRoute (Parser.s "game") 88 | ] 89 | 90 | 91 | parseUrlToPageAndCommand : Url -> ( Page, Cmd Msg ) 92 | parseUrlToPageAndCommand url = 93 | let 94 | routeMaybe : Maybe Route 95 | routeMaybe = 96 | Parser.parse routeParser { url | path = url.fragment |> Maybe.withDefault "", fragment = Nothing } 97 | in 98 | case routeMaybe of 99 | Just CategoriesRoute -> 100 | ( CategoriesPage Loading, getCategoriesRequest ) 101 | 102 | Just (ResultRoute score) -> 103 | ( ResultPage score, Cmd.none ) 104 | 105 | Just HomeRoute -> 106 | ( HomePage, Cmd.none ) 107 | 108 | Just GameRoute -> 109 | ( GamePage Loading, getQuestionsRequest ) 110 | 111 | Nothing -> 112 | ( HomePage, Cmd.none ) 113 | 114 | 115 | getQuestionsRequest : Cmd Msg 116 | getQuestionsRequest = 117 | Http.get 118 | { url = questionsUrl 119 | , expect = expectJson OnQuestionsFetched questionsDecoder 120 | } 121 | 122 | 123 | questionsDecoder : Decode.Decoder (List Question) 124 | questionsDecoder = 125 | Decode.field "results" (Decode.list questionDecoder) 126 | 127 | 128 | questionDecoder : Decode.Decoder Question 129 | questionDecoder = 130 | Decode.map3 131 | Question 132 | (Decode.field "question" Decode.string) 133 | correctAnswerDecoder 134 | answersDecoder 135 | 136 | 137 | correctAnswerDecoder : Decode.Decoder String 138 | correctAnswerDecoder = 139 | Decode.field "correct_answer" Decode.string 140 | 141 | 142 | answersDecoder : Decode.Decoder (List String) 143 | answersDecoder = 144 | Decode.map2 145 | (::) 146 | correctAnswerDecoder 147 | (Decode.field "incorrect_answers" (Decode.list Decode.string)) 148 | 149 | 150 | init : () -> Url -> Key -> ( Model, Cmd Msg ) 151 | init _ url key = 152 | let 153 | ( page, cmd ) = 154 | parseUrlToPageAndCommand url 155 | in 156 | ( Model key page, cmd ) 157 | 158 | 159 | update : Msg -> Model -> ( Model, Cmd Msg ) 160 | update msg model = 161 | case msg of 162 | OnCategoriesFetched (Ok categories) -> 163 | case model.page of 164 | CategoriesPage _ -> 165 | ( { model | page = CategoriesPage (Loaded categories) }, Cmd.none ) 166 | 167 | _ -> 168 | ( model, Cmd.none ) 169 | 170 | OnCategoriesFetched (Err err) -> 171 | case model.page of 172 | CategoriesPage _ -> 173 | ( { model | page = CategoriesPage OnError }, Cmd.none ) 174 | 175 | _ -> 176 | ( model, Cmd.none ) 177 | 178 | OnUrlRequest urlRequest -> 179 | case urlRequest of 180 | External url -> 181 | ( model, Navigation.load url ) 182 | 183 | Internal url -> 184 | ( model, Navigation.pushUrl model.key (Url.toString url) ) 185 | 186 | OnUrlChange url -> 187 | let 188 | ( page, cmd ) = 189 | parseUrlToPageAndCommand url 190 | in 191 | ( { model | page = page }, cmd ) 192 | 193 | OnQuestionsFetched (Ok (firstQuestion :: remainingQuestions)) -> 194 | let 195 | game = 196 | Game firstQuestion remainingQuestions 197 | in 198 | ( { model | page = GamePage (Loaded game) }, Cmd.none ) 199 | 200 | OnQuestionsFetched _ -> 201 | ( { model | page = GamePage OnError }, Cmd.none ) 202 | 203 | 204 | getCategoriesUrl : String 205 | getCategoriesUrl = 206 | "https://opentdb.com/api_category.php" 207 | 208 | 209 | categoriesDecoder : Decode.Decoder (List Category) 210 | categoriesDecoder = 211 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 212 | |> Decode.list 213 | |> Decode.field "trivia_categories" 214 | 215 | 216 | getCategoriesRequest : Cmd Msg 217 | getCategoriesRequest = 218 | Http.get 219 | { url = getCategoriesUrl 220 | , expect = expectJson OnCategoriesFetched categoriesDecoder 221 | } 222 | 223 | 224 | view : Model -> Html Msg 225 | view model = 226 | div [] 227 | [ case model.page of 228 | HomePage -> 229 | displayHomePage 230 | 231 | CategoriesPage categoriesModel -> 232 | displayCategoriesPage categoriesModel 233 | 234 | ResultPage score -> 235 | displayResultPage score 236 | 237 | GamePage gameRemoteData -> 238 | case gameRemoteData of 239 | Loading -> 240 | div [] [ text "Loading the questions..." ] 241 | 242 | OnError -> 243 | div [] [ text "An unknown error occurred while loading the questions" ] 244 | 245 | Loaded game -> 246 | gamePage game.currentQuestion 247 | ] 248 | 249 | 250 | displayHomePage : Html Msg 251 | displayHomePage = 252 | div [ class "gameOptions" ] 253 | [ h1 [] [ text "Quiz Game" ] 254 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 255 | , a [ class "btn btn-primary", href "#game" ] [ text "Play random questions" ] 256 | ] 257 | 258 | 259 | displayResultPage : Int -> Html Msg 260 | displayResultPage score = 261 | div [ class "score" ] 262 | [ h1 [] [ text ("Your score: " ++ String.fromInt score ++ " / 5") ] 263 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 264 | , p [] [ text (displayComment score) ] 265 | ] 266 | 267 | 268 | displayComment : Int -> String 269 | displayComment score = 270 | if score <= 3 then 271 | "Keep going, I'm sure you can do better!" 272 | 273 | else 274 | "Congrats, this is really good!" 275 | 276 | 277 | displayCategoriesPage : RemoteData (List Category) -> Html Msg 278 | displayCategoriesPage categories = 279 | div [] 280 | [ h1 [] [ text "Play within a given category" ] 281 | , displayCategoriesList categories 282 | ] 283 | 284 | 285 | displayCategoriesList : RemoteData (List Category) -> Html Msg 286 | displayCategoriesList categoriesRemote = 287 | case categoriesRemote of 288 | Loaded categories -> 289 | List.map displayCategory categories 290 | |> ul [ class "categories" ] 291 | 292 | OnError -> 293 | text "An error occurred while fetching categories" 294 | 295 | Loading -> 296 | text "Categories are loading..." 297 | 298 | 299 | displayCategory : Category -> Html Msg 300 | displayCategory category = 301 | let 302 | path = 303 | "#game/category/" ++ String.fromInt category.id 304 | in 305 | li [] 306 | [ a [ class "btn btn-primary", href path ] [ text category.name ] 307 | ] 308 | 309 | 310 | 311 | ------------------------------------------------------------------------------------------------------ 312 | -- Don't modify the code below, it displays the view and the tests and helps with testing your code -- 313 | ------------------------------------------------------------------------------------------------------ 314 | 315 | 316 | displayView : Model -> Document Msg 317 | displayView model = 318 | Document 319 | "Step 14" 320 | [ styles 321 | , view model 322 | , testsIframe 323 | ] 324 | -------------------------------------------------------------------------------- /Step15/README.md: -------------------------------------------------------------------------------- 1 | # Step 15: Run the Game! 2 | 3 | ## Goal 4 | 5 | Now that the game page is integrated to everything else, we can go back to it and allow the user to answer the questions and get its score! 6 | 7 | ## Instructions 8 | 9 | - When a use click on a response, the next question is displayed and its result is stored inside the model. 10 | - When all questions have been answered, the user is redirected to the `#result/{score}` page. 11 | 12 | ## Let's start! 13 | 14 | [See the result of your code](./Main.elm) (don't forget to refresh to see changes) 15 | 16 | Once the tests are passing, you are done with this workshop! 17 | 18 | However you can add some things to your application: 19 | 20 | - Shuffle the answers because now the first one is always the correct one 21 | - Save the score inside the local storage (search for how to use `ports`) -------------------------------------------------------------------------------- /Step15/Solution/Api.elm: -------------------------------------------------------------------------------- 1 | module Step15.Solution.Api exposing (getCategoriesCommand, getQuestionsCommand) 2 | 3 | import Http 4 | import Json.Decode as Decode 5 | import Step15.Solution.Types exposing (Category, Msg(..), Question) 6 | 7 | 8 | getCategoriesUrl : String 9 | getCategoriesUrl = 10 | "https://opentdb.com/api_category.php" 11 | 12 | 13 | getQuestionsUrl : String 14 | getQuestionsUrl = 15 | "https://opentdb.com/api.php?amount=5&type=multiple" 16 | 17 | 18 | categoriesDecoder : Decode.Decoder (List Category) 19 | categoriesDecoder = 20 | Decode.map2 Category (Decode.field "id" Decode.int) (Decode.field "name" Decode.string) 21 | |> Decode.list 22 | |> Decode.field "trivia_categories" 23 | 24 | 25 | getCategoriesCommand : Cmd Msg 26 | getCategoriesCommand = 27 | Http.send OnCategoriesFetched getCategoriesRequest 28 | 29 | 30 | getCategoriesRequest : Http.Request (List Category) 31 | getCategoriesRequest = 32 | Http.get getCategoriesUrl categoriesDecoder 33 | 34 | 35 | getQuestionsCommand : Cmd Msg 36 | getQuestionsCommand = 37 | Http.send OnQuestionsFetched getQuestionsRequest 38 | 39 | 40 | getQuestionsRequest : Http.Request (List Question) 41 | getQuestionsRequest = 42 | Http.get getQuestionsUrl questionsDecoder 43 | 44 | 45 | questionsDecoder : Decode.Decoder (List Question) 46 | questionsDecoder = 47 | Decode.field "results" (Decode.list questionDecoder) 48 | 49 | 50 | questionDecoder : Decode.Decoder Question 51 | questionDecoder = 52 | Decode.map3 Question (Decode.field "question" Decode.string) (Decode.field "correct_answer" Decode.string) answersDecoder 53 | 54 | 55 | answersDecoder : Decode.Decoder (List String) 56 | answersDecoder = 57 | Decode.map2 (::) (Decode.field "correct_answer" Decode.string) (Decode.field "incorrect_answers" (Decode.list Decode.string)) 58 | -------------------------------------------------------------------------------- /Step15/Solution/Main.elm: -------------------------------------------------------------------------------- 1 | module Step15.Solution.Main exposing (init, main) 2 | 3 | import Navigation exposing (..) 4 | import Step15.Solution.Api exposing (getCategoriesCommand, getQuestionsCommand) 5 | import Step15.Solution.Routing exposing (parseLocation) 6 | import Step15.Solution.Types exposing (Model, Msg(..), RemoteData(..), Route(..)) 7 | import Step15.Solution.Update exposing (update) 8 | import Step15.Solution.View exposing (displayTestsAndView) 9 | 10 | 11 | main : Program Never Model Msg 12 | main = 13 | Navigation.program OnLocationChange 14 | { init = init, update = update, view = displayTestsAndView, subscriptions = \model -> Sub.none } 15 | 16 | 17 | init : Location -> ( Model, Cmd Msg ) 18 | init location = 19 | let 20 | route = 21 | parseLocation location 22 | 23 | initialModel = 24 | Model Loading route 25 | 26 | initialCommand = 27 | case route of 28 | GameRoute _ -> 29 | Cmd.batch [ getCategoriesCommand, getQuestionsCommand ] 30 | 31 | _ -> 32 | getCategoriesCommand 33 | in 34 | ( initialModel, initialCommand ) 35 | -------------------------------------------------------------------------------- /Step15/Solution/README.md: -------------------------------------------------------------------------------- 1 | # Solution 2 | 3 | L'essentiel des modifications se trouvent : 4 | 5 | - Dans le fichier `View.elm`, on a rajouté un `onClick` sur les réponses 6 | - Dans `Types.elm` on a donc rajouté l'évènement `AnswerQuestion` correspondant 7 | - Dans `Update.elm` on traite ce nouvel évènement ; c'est le plus grand changement, prenez le temps de bien le regarder pour tout comprendre ! -------------------------------------------------------------------------------- /Step15/Solution/Routing.elm: -------------------------------------------------------------------------------- 1 | module Step15.Solution.Routing exposing (parseLocation) 2 | 3 | import Navigation exposing (Location) 4 | import Step15.Solution.Types exposing (RemoteData(..), Route(..)) 5 | import UrlParser exposing (..) 6 | 7 | 8 | matcher : Parser (Route -> a) a 9 | matcher = 10 | oneOf 11 | [ map HomepageRoute top 12 | , map CategoriesRoute (s "categories") 13 | , map ResultRoute (s "result" int) 14 | , map (GameRoute Loading) (s "game") 15 | ] 16 | 17 | 18 | parseLocation : Location -> Route 19 | parseLocation location = 20 | parseHash matcher location 21 | |> Maybe.withDefault HomepageRoute 22 | -------------------------------------------------------------------------------- /Step15/Solution/Types.elm: -------------------------------------------------------------------------------- 1 | module Step15.Solution.Types exposing (AnsweredQuestion, Category, Game, Model, Msg(..), Question, QuestionStatus(..), RemoteData(..), Route(..)) 2 | 3 | import Http exposing (Error) 4 | import Navigation exposing (Location) 5 | 6 | 7 | type alias Model = 8 | { categories : RemoteData (List Category) 9 | , route : Route 10 | } 11 | 12 | 13 | type Msg 14 | = OnCategoriesFetched (Result Error (List Category)) 15 | | OnQuestionsFetched (Result Error (List Question)) 16 | | AnswerQuestion String 17 | | OnLocationChange Location 18 | 19 | 20 | type alias Category = 21 | { id : Int, name : String } 22 | 23 | 24 | type RemoteData a 25 | = Loading 26 | | Loaded a 27 | | OnError 28 | 29 | 30 | type alias Question = 31 | { question : String 32 | , correctAnswer : String 33 | , answers : List String 34 | } 35 | 36 | 37 | type alias AnsweredQuestion = 38 | { question : Question 39 | , status : QuestionStatus 40 | } 41 | 42 | 43 | type QuestionStatus 44 | = Correct 45 | | Incorrect 46 | 47 | 48 | type alias Game = 49 | { answeredQuestions : List AnsweredQuestion 50 | , currentQuestion : Question 51 | , remainingQuestions : List Question 52 | } 53 | 54 | 55 | type Route 56 | = HomepageRoute 57 | | CategoriesRoute 58 | | ResultRoute Int 59 | | GameRoute (RemoteData Game) 60 | -------------------------------------------------------------------------------- /Step15/Solution/Update.elm: -------------------------------------------------------------------------------- 1 | module Step15.Solution.Update exposing (update) 2 | 3 | import Navigation 4 | import Step15.Solution.Api exposing (getQuestionsCommand) 5 | import Step15.Solution.Routing exposing (parseLocation) 6 | import Step15.Solution.Types exposing (AnsweredQuestion, Category, Game, Model, Msg(..), Question, QuestionStatus(..), RemoteData(..), Route(..)) 7 | 8 | 9 | update : Msg -> Model -> ( Model, Cmd Msg ) 10 | update msg model = 11 | case msg of 12 | OnCategoriesFetched (Ok categories) -> 13 | ( { model | categories = Loaded categories }, Cmd.none ) 14 | 15 | OnCategoriesFetched (Err err) -> 16 | ( { model | categories = OnError }, Cmd.none ) 17 | 18 | OnQuestionsFetched (Ok []) -> 19 | ( model, getQuestionsCommand ) 20 | 21 | OnQuestionsFetched (Ok (currentQuestion :: remainingQuestions)) -> 22 | let 23 | game = 24 | Game [] currentQuestion remainingQuestions 25 | in 26 | ( { model | route = GameRoute (Loaded game) }, Cmd.none ) 27 | 28 | OnQuestionsFetched (Err _) -> 29 | ( { model | route = GameRoute OnError }, Cmd.none ) 30 | 31 | AnswerQuestion answer -> 32 | case model.route of 33 | GameRoute (Loaded game) -> 34 | let 35 | responseStatus = 36 | if answer == game.currentQuestion.correctAnswer then 37 | Correct 38 | 39 | else 40 | Incorrect 41 | 42 | answeredQuestions = 43 | AnsweredQuestion game.currentQuestion responseStatus :: game.answeredQuestions 44 | in 45 | case game.remainingQuestions of 46 | [] -> 47 | let 48 | score = 49 | calculateScore answeredQuestions 50 | in 51 | ( { model | route = ResultRoute score }, Navigation.newUrl ("#result/" ++ toString score) ) 52 | 53 | newQuestion :: remainingQuestions -> 54 | let 55 | newGame = 56 | Game answeredQuestions newQuestion remainingQuestions 57 | in 58 | ( { model | route = GameRoute (Loaded newGame) }, Cmd.none ) 59 | 60 | _ -> 61 | ( model, Cmd.none ) 62 | 63 | OnLocationChange location -> 64 | let 65 | route = 66 | parseLocation location 67 | 68 | command = 69 | case route of 70 | GameRoute _ -> 71 | getQuestionsCommand 72 | 73 | _ -> 74 | Cmd.none 75 | in 76 | ( { model | route = route }, command ) 77 | 78 | 79 | calculateScore : List AnsweredQuestion -> Int 80 | calculateScore answeredQuestions = 81 | let 82 | scores = 83 | List.map 84 | (\answer -> 85 | if answer.status == Correct then 86 | 1 87 | 88 | else 89 | 0 90 | ) 91 | answeredQuestions 92 | in 93 | List.foldl (+) 0 scores 94 | -------------------------------------------------------------------------------- /Step15/Solution/View.elm: -------------------------------------------------------------------------------- 1 | module Step15.Solution.View exposing (displayTestsAndView, view) 2 | 3 | import ElmEscapeHtml exposing (unescape) 4 | import Html exposing (Html, a, div, h1, h2, iframe, li, program, text, ul) 5 | import Html.Attributes exposing (class, href, src, style) 6 | import Html.Events exposing (onClick) 7 | import Step15.Solution.Types exposing (..) 8 | 9 | 10 | view : Model -> Html Msg 11 | view model = 12 | div [] 13 | [ displayPage model ] 14 | 15 | 16 | displayPage : Model -> Html Msg 17 | displayPage model = 18 | case model.route of 19 | HomepageRoute -> 20 | displayHomepage model 21 | 22 | ResultRoute score -> 23 | displayResultPage score 24 | 25 | CategoriesRoute -> 26 | displayCategoriesPage model.categories 27 | 28 | GameRoute remoteGame -> 29 | displayGamePage remoteGame 30 | 31 | 32 | displayHomepage : Model -> Html Msg 33 | displayHomepage model = 34 | div [ class "gameOptions" ] 35 | [ h1 [] [ text "Quiz Game" ] 36 | , a [ class "btn btn-primary", href "#game" ] [ text "Play random questions" ] 37 | , a [ class "btn btn-primary", href "#categories" ] [ text "Play from a category" ] 38 | ] 39 | 40 | 41 | displayCategoriesPage : RemoteData (List Category) -> Html Msg 42 | displayCategoriesPage categories = 43 | div [] 44 | [ h1 [] [ text "Play within a given category" ] 45 | , displayCategoriesList categories 46 | ] 47 | 48 | 49 | displayResultPage : Int -> Html Msg 50 | displayResultPage score = 51 | div [ class "score" ] 52 | [ h1 [] [ text ("Your score: " ++ toString score ++ " / 5") ] 53 | , a [ class "btn btn-primary", href "#" ] [ text "Replay" ] 54 | ] 55 | 56 | 57 | displayGamePage : RemoteData Game -> Html Msg 58 | displayGamePage remoteGame = 59 | case remoteGame of 60 | Loading -> 61 | text "Loading the questions..." 62 | 63 | OnError -> 64 | text "An unknown error occurred while loading the questions." 65 | 66 | Loaded game -> 67 | div [] [ displayGame game.currentQuestion ] 68 | 69 | 70 | displayCategoriesList : RemoteData (List Category) -> Html Msg 71 | displayCategoriesList categoriesRemote = 72 | case categoriesRemote of 73 | Loaded categories -> 74 | List.map displayCategory categories 75 | |> ul [ class "categories" ] 76 | 77 | OnError -> 78 | text "An error occurred while fetching categories" 79 | 80 | Loading -> 81 | text "Categories are loading..." 82 | 83 | 84 | displayCategory : Category -> Html Msg 85 | displayCategory category = 86 | let 87 | path = 88 | "#game/category/" ++ toString category.id 89 | in 90 | li [] 91 | [ a [ class "btn btn-primary", href path ] [ text category.name ] 92 | ] 93 | 94 | 95 | displayGame : Question -> Html Msg 96 | displayGame question = 97 | div [] 98 | [ h2 [ class "question" ] [ unescape question.question |> text ] 99 | , ul [ class "answers" ] (List.map displayAnswer question.answers) 100 | ] 101 | 102 | 103 | displayAnswer : String -> Html Msg 104 | displayAnswer answer = 105 | li [] [ a [ class "btn btn-primary", onClick (AnswerQuestion answer) ] [ unescape answer |> text ] ] 106 | 107 | 108 | displayTestsAndView : Model -> Html Msg 109 | displayTestsAndView model = 110 | div [] 111 | [ div [ class "jumbotron" ] [ view model ] 112 | ] 113 | -------------------------------------------------------------------------------- /Step15/Tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Step15.Tests.Tests exposing (afterAnsweringLastQuestionWeShouldBeRedirectedToProperResult, afterAnsweringLastQuestionWeShouldBeRedirectedToResult, afterClickingTheProperAnswerTheModelShouldBeUpdated, afterClickingTheWrongAnswerTheModelShouldBeUpdated, categoriesUrl, fakeGameLocation, initialModel, main, questionsUrl, randomQuestionFuzz, randomTwoQuestionsListFuzz, whenQuestionsAreLoadedTheFirstQuestionShouldBeDisplayed) 2 | 3 | import ElmEscapeHtml exposing (unescape) 4 | import Expect exposing (Expectation) 5 | import Fuzz exposing (intRange) 6 | import Http exposing (Error(..)) 7 | import Json.Encode as Encode 8 | import Step15.Main exposing (init) 9 | import Test exposing (Test, concat, describe, fuzz, test) 10 | import Test.Html.Event exposing (Event, click, simulate, toResult) 11 | import Test.Html.Query as Query 12 | import Test.Html.Selector exposing (tag, text) 13 | 14 | 15 | fakeGameLocation = 16 | { href = "http://localhost:8080/Step15/index.html#game" 17 | , host = "localhost" 18 | , hostname = "localhost" 19 | , protocol = "http:" 20 | , origin = "http://localhost:8080/Step15/" 21 | , port_ = "8000" 22 | , pathname = "/Step15/index.html" 23 | , search = "" 24 | , hash = "#game" 25 | , username = "" 26 | , password = "" 27 | } 28 | 29 | 30 | categoriesUrl : String 31 | categoriesUrl = 32 | "https://opentdb.com/api_category.php" 33 | 34 | 35 | questionsUrl : String 36 | questionsUrl = 37 | "https://opentdb.com/api.php?amount=5&type=multiple" 38 | 39 | 40 | main = 41 | concat 42 | [ whenQuestionsAreLoadedTheFirstQuestionShouldBeDisplayed 43 | , afterClickingTheProperAnswerTheModelShouldBeUpdated 44 | , afterClickingTheWrongAnswerTheModelShouldBeUpdated 45 | , afterAnsweringLastQuestionWeShouldBeRedirectedToResult 46 | ] 47 | 48 | 49 | initialModel = 50 | init fakeGameLocation 51 | 52 | 53 | whenQuestionsAreLoadedTheFirstQuestionShouldBeDisplayed : Test 54 | whenQuestionsAreLoadedTheFirstQuestionShouldBeDisplayed = 55 | fuzz randomTwoQuestionsListFuzz "When questions are loaded, the first question should be displayed" <| 56 | \randomQuestions -> 57 | case randomQuestions of 58 | [ question1, question2 ] -> 59 | let 60 | updatedView = 61 | init fakeGameLocation 62 | |> Tuple.first 63 | |> update (OnQuestionsFetched <| Ok [ question1, question2 ]) 64 | |> Tuple.first 65 | |> view 66 | in 67 | updatedView 68 | |> Query.fromHtml 69 | |> Query.has [ text (unescape question1.question) ] 70 | 71 | _ -> 72 | Expect.pass 73 | 74 | 75 | afterClickingTheProperAnswerTheModelShouldBeUpdated : Test 76 | afterClickingTheProperAnswerTheModelShouldBeUpdated = 77 | fuzz randomTwoQuestionsListFuzz "After clicking the proper answer, model should indicate that it's correct and go to next question" <| 78 | \randomQuestions -> 79 | case randomQuestions of 80 | [ question1, question2 ] -> 81 | let 82 | initialModel = 83 | init fakeGameLocation 84 | |> Tuple.first 85 | |> update (OnQuestionsFetched <| Ok [ question1, question2 ]) 86 | |> Tuple.first 87 | 88 | modelAfterClickOnProperAnswer = 89 | view initialModel 90 | |> Query.fromHtml 91 | |> Query.findAll [ tag "a" ] 92 | |> Query.first 93 | |> simulate click 94 | |> toResult 95 | |> Result.map (\msg -> update msg initialModel) 96 | |> Result.map Tuple.first 97 | 98 | expectedGame = 99 | Game [ AnsweredQuestion question1 Correct ] question2 [] 100 | in 101 | case modelAfterClickOnProperAnswer of 102 | Err _ -> 103 | Expect.fail "A click on an answer should generate a message to update the model" 104 | 105 | Ok model -> 106 | Expect.equal (Model Loading <| GameRoute (Loaded expectedGame)) model 107 | 108 | _ -> 109 | Expect.pass 110 | 111 | 112 | afterClickingTheWrongAnswerTheModelShouldBeUpdated : Test 113 | afterClickingTheWrongAnswerTheModelShouldBeUpdated = 114 | fuzz randomTwoQuestionsListFuzz "After clicking the wrong answer, model should indicate that it's incorrect and go to next question" <| 115 | \randomQuestions -> 116 | case randomQuestions of 117 | [ question1, question2 ] -> 118 | let 119 | initialModel = 120 | init fakeGameLocation 121 | |> Tuple.first 122 | |> update (OnQuestionsFetched <| Ok [ question1, question2 ]) 123 | |> Tuple.first 124 | 125 | modelAfterClickOnWrongAnswer = 126 | view initialModel 127 | |> Query.fromHtml 128 | |> Query.findAll [ tag "a" ] 129 | |> Query.index 1 130 | |> simulate click 131 | |> toResult 132 | |> Result.map (\msg -> update msg initialModel) 133 | |> Result.map Tuple.first 134 | 135 | expectedGame = 136 | Game [ AnsweredQuestion question1 Incorrect ] question2 [] 137 | in 138 | case modelAfterClickOnWrongAnswer of 139 | Err _ -> 140 | Expect.fail "A click on an answer should generate a message to update the model" 141 | 142 | Ok model -> 143 | Expect.equal (Model Loading <| GameRoute (Loaded expectedGame)) model 144 | 145 | _ -> 146 | Expect.pass 147 | 148 | 149 | afterAnsweringLastQuestionWeShouldBeRedirectedToResult : Test 150 | afterAnsweringLastQuestionWeShouldBeRedirectedToResult = 151 | fuzz randomTwoQuestionsListFuzz "After answering the last question, we should be redirect to result page" <| 152 | \randomQuestions -> 153 | case randomQuestions of 154 | [ question1, question2 ] -> 155 | let 156 | initialModel = 157 | Game [ AnsweredQuestion question1 Correct ] question2 [] 158 | |> Loaded 159 | |> GameRoute 160 | |> Model Loading 161 | 162 | modelAfterClickOnAnswer = 163 | view initialModel 164 | |> Query.fromHtml 165 | |> Query.findAll [ tag "a" ] 166 | |> Query.index 1 167 | |> simulate click 168 | |> toResult 169 | |> Result.map (\msg -> update msg initialModel) 170 | |> Result.map Tuple.first 171 | in 172 | case modelAfterClickOnAnswer of 173 | Err _ -> 174 | Expect.fail "A click on an answer should generate a message to update the model" 175 | 176 | Ok { route } -> 177 | case route of 178 | ResultRoute _ -> 179 | Expect.pass 180 | 181 | _ -> 182 | Expect.fail "The user should be redirected to the score page" 183 | 184 | _ -> 185 | Expect.pass 186 | 187 | 188 | afterAnsweringLastQuestionWeShouldBeRedirectedToProperResult : Test 189 | afterAnsweringLastQuestionWeShouldBeRedirectedToProperResult = 190 | fuzz randomTwoQuestionsListFuzz "After answering the last question, we should be redirect to result page with the proper score" <| 191 | \randomQuestions -> 192 | case randomQuestions of 193 | [ question1, question2 ] -> 194 | let 195 | initialModel = 196 | Game [ AnsweredQuestion question1 Correct ] question2 [] 197 | |> Loaded 198 | |> GameRoute 199 | |> Model Loading 200 | 201 | modelAfterClickOnWrongAnswer = 202 | view initialModel 203 | |> Query.fromHtml 204 | |> Query.findAll [ tag "a" ] 205 | |> Query.index 1 206 | |> simulate click 207 | |> toResult 208 | |> Result.map (\msg -> update msg initialModel) 209 | |> Result.map Tuple.first 210 | in 211 | case modelAfterClickOnWrongAnswer of 212 | Err _ -> 213 | Expect.fail "A click on an answer should generate a message to update the model" 214 | 215 | Ok { route } -> 216 | case route of 217 | ResultRoute 1 -> 218 | Expect.pass 219 | 220 | _ -> 221 | Expect.fail "The user should be redirected to the score page with score 1" 222 | 223 | _ -> 224 | Expect.pass 225 | 226 | 227 | randomTwoQuestionsListFuzz : Fuzz.Fuzzer (List Question) 228 | randomTwoQuestionsListFuzz = 229 | Fuzz.map2 230 | (List.singleton >> (\b a -> (::) a b)) 231 | randomQuestionFuzz 232 | randomQuestionFuzz 233 | 234 | 235 | randomQuestionFuzz : Fuzz.Fuzzer Question 236 | randomQuestionFuzz = 237 | Fuzz.map5 238 | (\question answer1 answer2 answer3 answer4 -> 239 | Question question answer1 [ answer1, answer2, answer3, answer4 ] 240 | ) 241 | Fuzz.string 242 | Fuzz.string 243 | Fuzz.string 244 | Fuzz.string 245 | Fuzz.string 246 | -------------------------------------------------------------------------------- /Utils/Utils.elm: -------------------------------------------------------------------------------- 1 | module Utils.Utils exposing (styles, testStyles, testsIframe) 2 | 3 | import Html exposing (Html, div, iframe, node) 4 | import Html.Attributes exposing (href, rel, src, style) 5 | 6 | 7 | testsIframe : Html a 8 | testsIframe = 9 | iframe 10 | [ src "./Tests/Tests.elm" 11 | , style "display" "block" 12 | , style "width" "90%" 13 | , style "margin" "auto" 14 | , style "height" "500px" 15 | , style "margin-top" "2em" 16 | ] 17 | [] 18 | 19 | 20 | styles : Html a 21 | styles = 22 | div [] 23 | [ node "link" 24 | [ rel "stylesheet" 25 | , href "/Utils/bootstrap.min.css" 26 | ] 27 | [] 28 | , node "link" 29 | [ rel "stylesheet" 30 | , href "/Utils/style.css" 31 | ] 32 | [] 33 | ] 34 | 35 | 36 | testStyles : Html a 37 | testStyles = 38 | node "link" 39 | [ rel "stylesheet" 40 | , href "/Utils/test-style.css" 41 | ] 42 | [] 43 | -------------------------------------------------------------------------------- /Utils/images/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step1.png -------------------------------------------------------------------------------- /Utils/images/step12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step12.png -------------------------------------------------------------------------------- /Utils/images/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step2.png -------------------------------------------------------------------------------- /Utils/images/step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step3.png -------------------------------------------------------------------------------- /Utils/images/step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step4.png -------------------------------------------------------------------------------- /Utils/images/step6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step6.png -------------------------------------------------------------------------------- /Utils/images/step7-goal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step7-goal.png -------------------------------------------------------------------------------- /Utils/images/step7-tea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrenat/elm-workshop/e99f6e56443ed2ef855c9f2e8c2664199e0ab310/Utils/images/step7-tea.png -------------------------------------------------------------------------------- /Utils/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | text-align: center; 3 | } 4 | 5 | h1 { 6 | margin-bottom: 5vh; 7 | } 8 | 9 | .gameOptions { 10 | text-align: center; 11 | } 12 | 13 | .gameOptions a { 14 | display: block; 15 | max-width: 300px; 16 | margin: 1em auto 0; 17 | } 18 | 19 | .score { 20 | text-align: center; 21 | } 22 | 23 | .categories { 24 | display: grid; 25 | grid-template-columns: repeat(auto-fit, 366px); 26 | grid-template-columns: repeat(auto-fit, minmax(366px, 1fr)); 27 | grid-gap: 20px; 28 | padding: 0; 29 | margin: auto; 30 | text-align: center; 31 | list-style-type: none; 32 | } 33 | 34 | .categories a { 35 | width: 100%; 36 | height: 100%; 37 | } 38 | 39 | .question { 40 | text-align: center; 41 | margin: auto auto 3em; 42 | } 43 | 44 | .answers { 45 | display: grid; 46 | grid-template-columns: repeat(2, 1fr); 47 | grid-gap: 20px; 48 | max-width: 70%; 49 | padding: 0; 50 | margin: auto; 51 | text-align: center; 52 | list-style-type: none; 53 | } 54 | 55 | 56 | .answers a.btn { 57 | color: white; 58 | } 59 | 60 | 61 | /*Trick to avoid elm-reactor errors to be centered, which is really not readable...*/ 62 | div [style="display: block; white-space: pre; background-color: rgb(39, 40, 34); padding: 2em;"] { 63 | text-align: left; 64 | } -------------------------------------------------------------------------------- /Utils/test-style.css: -------------------------------------------------------------------------------- 1 | .test-pass { 2 | color: #1e7e34; 3 | } 4 | 5 | .test-fail { 6 | color: darkred; 7 | } -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | ".", 5 | "./Utils" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "avh4/elm-program-test": "2.3.2", 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.2", 13 | "elm/html": "1.0.0", 14 | "elm/http": "2.0.0", 15 | "elm/json": "1.1.3", 16 | "elm/random": "1.0.0", 17 | "elm/url": "1.0.0", 18 | "elm-explorations/test": "1.2.2", 19 | "jgrenat/elm-html-test-runner": "1.0.3", 20 | "marcosh/elm-html-to-unicode": "1.0.3" 21 | }, 22 | "indirect": { 23 | "avh4/elm-fifo": "1.0.4", 24 | "elm/bytes": "1.0.8", 25 | "elm/file": "1.0.5", 26 | "elm/time": "1.0.0", 27 | "elm/virtual-dom": "1.0.2", 28 | "elm-community/list-extra": "8.2.2", 29 | "elm-community/maybe-extra": "5.0.0" 30 | } 31 | }, 32 | "test-dependencies": { 33 | "direct": {}, 34 | "indirect": {} 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /syntax-help.md: -------------------------------------------------------------------------------- 1 | # Syntax Cheat Sheet 2 | 3 | ## Comments 4 | 5 | ```elm 6 | -- single line comment 7 | {- multiline comment 8 | -} 9 | ``` 10 | 11 | ## Basic types 12 | 13 | ```elm 14 | True : Bool 15 | False : Bool 16 | 17 | 13 : number -- Int or Float depending on usage 18 | 4.2 : Float 19 | 20 | "hello world" : String 21 | 'a' : Char 22 | ``` 23 | 24 | ## String manipulation 25 | 26 | ```elm 27 | "hello" ++ " world" -- Concatenation 28 | "age: " ++ (String.fromInt 42) -- Concatenation with numbers 29 | ``` 30 | 31 | ## Functions 32 | 33 | ```elm 34 | add a b = a + b -- Definition 35 | add 3 5 -- Example of use, returns 8 36 | 37 | anotherAdd = (\a b -> a + b) -- Lambda / anonymous function 38 | anotherAdd 3 5 -- Returns 8 39 | ``` 40 | 41 | ## Lists 42 | 43 | ```elm 44 | ["elem1", "elem2"] 45 | 1 :: [2,3,4] -- Equals [1, 2, 3, 4] 46 | List.map (\a -> a * 2) [1, 2, 3, 4] -- Equals [2, 4, 6, 8] 47 | ``` 48 | 49 | ## Tuples 50 | 51 | ```elm 52 | ("text", 42) -- Tuple with two values 53 | (34, 23, "text") -- Tuple with 3 values 54 | ``` 55 | 56 | [More details about tuples](https://guide.elm-lang.org/core_language.html#tuples) 57 | 58 | ## Conditions 59 | 60 | ```elm 61 | myAge = 19 62 | if myAge >= 18 then 63 | "overage" 64 | else 65 | "underage" 66 | -- Returns "overage" (this is an expression, not a statement) 67 | ``` 68 | 69 | 70 | ## Records 71 | 72 | ```elm 73 | myUser = { username = "Marcus", password = "Secr3T" } 74 | myUser.username -- Returns "Marcus" 75 | 76 | myUserWithChangedPassword = { myUser | password = "M0r3Secr3T" } 77 | myUser.password -- Returns "Secr3T" 78 | myUserWithChangedPassword.password -- Returns "M0r3Secr3T" 79 | ``` 80 | 81 | # Type 82 | 83 | ```elm 84 | -- Type alias 85 | type alias User = { username : String, password : String } 86 | 87 | -- Gives you a "constructor" function 'User' 88 | User "Marcus" "Secr3T" == { username = "Marcus", password = "Secr3T" } 89 | 90 | 91 | -- Custom type 92 | type RemoteString = NotLoaded | Loading | Loaded String | OnError Int 93 | 94 | {- For null values, you can use the Maybe custom type 95 | that is either "Just something" or "Nothing": 96 | -} 97 | Just "value" : Maybe.Maybe String 98 | Nothing : Maybe.Maybe a 99 | ``` 100 | 101 | [More informations about custom types](https://guide.elm-lang.org/types/custom_types.html) 102 | 103 | 104 | ## Cases 105 | Match values against patterns 106 | 107 | ```elm 108 | case myListOfString of 109 | [] -> -- Handles empty list 110 | "empty" 111 | 112 | head :: others -> -- Store head of the list in head and the rest of the list in the List others 113 | head 114 | 115 | case myStringMaybe of 116 | Just value -> 117 | value 118 | Nothing -> 119 | "empty string" 120 | 121 | case myNumber of 122 | 0 -> 123 | "the number is 0" 124 | 1 -> 125 | "the number is 1" 126 | _ -> 127 | "the number is neither 0 nor 1" 128 | ``` 129 | 130 | ## Let Expressions 131 | 132 | let these values be defined in this specific expression. 133 | 134 | ```elm 135 | let 136 | days = 137 | 2 138 | seconds = 139 | numberOfDays * 24 * 60 * 60 140 | in 141 | (String.fromInt seconds) " seconds in " ++ (String.fromInt days) ++ " days" 142 | ``` 143 | 144 | Allows to split complex expressions into smaller definitions to ease the read. Use when needed but be careful, sometimes it is better to extract code into functions! 145 | 146 | 147 | ## Modules 148 | 149 | ```elm 150 | module MyModule exposing (..) 151 | 152 | import List exposing (..) -- import module and expose everything 153 | import List exposing ( map, foldl ) -- exposes only map and foldl 154 | ``` 155 | 156 | Qualified imports are preferred. Module names must match their file name, so module Parser.Utils needs to be in file Parser/Utils.elm . 157 | 158 | 159 | ## Type Annotations 160 | 161 | You can learn more about type annotations [here](https://guide.elm-lang.org/types/reading_types.html#type-annotations). 162 | --------------------------------------------------------------------------------