├── .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 | 
18 |
19 | Here is the HTML structure you should match in order to pass the tests:
20 |
21 | ```html
22 |
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------