Writing A Word Memory Game In Elm - Part 3: Rethinking the Model
Mickey
Posted on April 11, 2019
This is part 3 of the series "Writing A Word Memory Game In Elm", find:
- Part 1: Setting Up an Elm Application With Parcel
- Part 2: Modeling And Building a Basic Word Memory Game
- Part 3: Rethinking the Model
- Part 4: Spicing Things Up With Randomness
- Part 5 - More Randomness And More Game
The game works now. Cool. But looking at the current Model
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String )
type alias Words =
List Word
type alias Model =
{ sentence : String
, chosenWords : Words
, chosenSentence : Words
}
we can spot several problems:
- The
chosenSentence
andchosenWords
are 2 separate parts of theModel
and we can accidentally make them out of sync - Since
chosenWords
is of typeWords
, it may contain alsoSentenceWord
- The
sentence
may also become out of sync with the rest of theModel
- We also have some code smell in
viewHiddenWord
:
viewHiddenWord : Word -> List Word -> Html msg
viewHiddenWord hiddenWord chosenWords =
-- omitted code
wordElement : Word -> Html Msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
-- omitted code
And probably more.
In Elm we want to invest additional time into a good planning of our model and in getting it as close as possible to a form that will eliminate the invalid state of our application.
This idea, not new I suppose, was introduced by Richard Feldman at Elm-Conf 2016 in his amazing (as always) talk:
Since then we often hear "make impossible states impossible" in different communities, not only Elm.
How can we improve our Model
and eliminate possibilities for the invalid state and code smell?
Instead of having chosenWords
and sentenceWords
- we can just have sentence
property and rename the sentence
to sentenceOriginal
:
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String )
type alias Words =
List Word
type alias Model =
{ sentenceOriginal : String
, sentence : Words
}
Looks better, but now we lost the original sentence's word or player's input, since there is only 1 String
placeholder. Well... we can have the player's input and the original word in the same type constructor:
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String, String ) -- index, answer, player's choice
Also, maybe instead of having an index
that can also get out of sync, we can use List.indexedMap
when rendering the sentence
which will give us the index:
type Word
= SentenceWord String
| HiddenWord String String -- answer, player's choice
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
We can go even further into modeling and replace String
with a concrete types:
type PlayerChoice =
PlayerChoice String
type Answer =
Answer String
type Word
= SentenceWord String
| HiddenWord Answer PlayerChoice
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
This will prevent us from passing the answer (which is String
) and the player's input (also String
) to HiddenWord
, in the wrong order. The code now is also more readable.
We have almost all we need now. The ability to have a random order of chosen words, however, is still missing. To solve this, we can add a SortKey
to the HiddenWord
:
type PlayerChoice =
PlayerChoice String
type Answer =
Answer String
type SortKey =
SortKey Int
type Word
= SentenceWord String
| HiddenWord SortKey Answer PlayerChoice
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
When the type constructor grows, it may be better to turn it to a record. Let's convert HiddenWord
into a record:
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
We don't need the SortKey
, just Int
is sufficient:
type PlayerChoice =
PlayerChoice String
type Answer =
Answer String
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
type Word
= SentenceWord String
| HiddenWord HiddenWord
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
At this point Elm will complain about 2 HiddenWord
type definitions.
We need to have different names. I renamed SentenceWord
and HiddenWord
to SentenceWrd
and HiddenWrd
:
type Word
= SentenceWrd String
| HiddenWrd HiddenWord
After this change we have a lot to refactor. Luckily we have our back covered with Elm compiler. Let's update the current initialModel
:
type alias Model =
{ sentence : String
, chosenWords : Words
, chosenSentence : Words
}
initialModel : Model
initialModel =
{ originalSentence = "The pen is mightier than the sword"
, chosenWords =
[ HiddenWord ( 1, "pen" )
, HiddenWord ( 6, "sword" )
, HiddenWord ( 3, "mightier" )
]
, chosenSentence =
[ SentenceWord ( 0, "The" )
, HiddenWord ( 1, "" )
, SentenceWord ( 2, "is" )
, HiddenWord ( 3, "" )
, SentenceWord ( 4, "than" )
, SentenceWord ( 5, "the" )
, HiddenWord ( 6, "" )
]
}
to this:
type alias Model =
{ sentence : Words
}
initialModel : Model
initialModel =
{ sentence =
[ SentenceWrd "The"
, HiddenWrd
{ sortKey = 1
, answer = Answer "pen"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "is"
, HiddenWrd
{ sortKey = 3
, answer = Answer "mightier"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "than"
, SentenceWrd "the"
, HiddenWrd
{ sortKey = 2
, answer = Answer "sword"
, playerChoice = PlayerChoice ""
}
]
}
We hardcoded the "random" order of the chosen words by setting the sortKey
, also removed the originalSentence
from the model (for now) and renamed chosenSentence
to sentence
.
The update
function is the first we can refactor easily. Let's change:
-
newSentence
to useList.indexedMap
-
updateWord
to take an index as the first argument
update : Msg -> Model -> Model
update msg model =
case msg of
WordChanged index wordString ->
let
updateWord : Int -> Word -> Word
updateWord wordIndex word =
case word of
HiddenWrd hiddenWord ->
if wordIndex == index then
HiddenWrd
{ hiddenWord
| playerChoice = PlayerChoice wordString
}
else
word
_ ->
word
newSentence : Words
newSentence =
List.indexedMap updateWord model.sentence
in
{ model | sentence = newSentence }
We now have an index
of the word to update with the player's input, and wordIndex
of the current word we are running List.indexedMap
on. If both indexes are equal, we need to update the playerChoice
field of the HiddenWrd
.
Now there are a lot of compiler errors. Let's go step by step and comment out all the functions with errors, that is viewSentence
, viewHiddenWord
, viewChosenWords
and the invocation of them in the view
:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ text model.sentence ]
-- , viewSentence model.chosenSentence model.chosenWords
-- , viewChosenWords model.chosenWords model.chosenSentence
]
]
]
The compilers complains about text model.sentence
because text
expects a String
as input, but after the refactoring model.sentence
is not a simple String
but Words
(which is itself a type alias for List Word
).
First we need something that can convert a Word
into a String
:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd hiddenWord ->
case hiddenWord.answer of
Answer answerString ->
answerString
If word
is SenteceWrd
, we'll take the String
part of it. If it's the HiddenWrd
, we'll take the Answer
from it and then pattern match to get its String
part.
Let's add a viewOriginalSentence
function to render the sentence:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
(List.map
(\word ->
wordToString word |> text
)
words
)
We are mapping each Word
to a String
and passing the String
to Html.text
.
Let's update the view
function to use viewOriginalSentence
:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ viewOriginalSentence model.sentence ]
-- , viewSentence model.chosenSentence model.chosenWords
]
-- , viewChosenWords model.chosenWords model.chosenSentence
]
]
The application now compiles! Lets see how it looks:
Well... all the words are written without any spaces between them. We need to add some space, one way we can do it is by adding a space after each word:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
(List.map
(\word ->
wordToString word ++ " " |> text
)
words
)
Or we can use List.intersperse
that "Places the given value between all members of the given list.":
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
(words
|> List.map wordToString
|> List.intersperse " "
|> List.map text
)
or event simpler with String.join
:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
[ words
|> List.map wordToString
|> String.join " "
|> text
]
And now the game looks better:
Let's do a small refactor before moving on. Let's add answerToString
helper function to convert Answer
into a String
. We can use it from wordToString
and it may come handy later when comparing the player's choice with the answer:
answerToString : Answer -> String
answerToString (Answer wordString) =
wordString
wordToString : Word -> String
wordToString word =
case word of
HiddenWrd hiddenWord ->
answerToString hiddenWord.answer
SentenceWrd wordString ->
wordString
Great. The next in line is the view for chosen words. Let's uncomment and examine it:
viewChosenWords : Words -> Words -> Html msg
viewChosenWords chosenWords sentenceWords =
let
viewChosenWord : Word -> Html msg
viewChosenWord chosenWord =
case chosenWord of
HiddenWord ( _, wordString ) ->
let
isCorrectGuess : Bool
isCorrectGuess =
List.member chosenWord sentenceWords
className : String
className =
if isCorrectGuess then
"has-text-success"
else
"has-text-grey-light"
in
li []
[ span [ class className ]
[ text wordString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
SentenceWord _ ->
text ""
in
ul [] (List.map viewChosenWord chosenWords)
The firs thing that we can improve is the signature. Now it takes chosenWords
as Words
, but it should only handle List HiddenWord
.
Previously, we needed the sentenceWords
to hint if the player's choice was correct. Now we have all the information in HiddenWord
, both playerChoice
and answer
!
This means that the signature can be simplified and made to express the input better - we can only pass List HiddenWord
here:
viewChosenWords : List HiddenWord -> Html msg
viewChosenWords chosenWords =
Let's also rename viewChosenWords
to viewHiddenWords
because hidden
and chosen
are used and it's confusing:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
We also need a way to get the List HiddenWord
from our model. Let's add this ability. We need to filter out the HiddenWrd
and take HiddenWord
from it (note "Word" vs. "Wrd"). And Elm has us covered with List.filterMap:
hiddenWords : Words -> List HiddenWord
hiddenWords sentence =
List.filterMap
(\word ->
case word of
HiddenWrd hiddenWord ->
Just hiddenWord
_ ->
Nothing
)
sentence
Now we can update view
to call it:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ viewOriginalSentence model.sentence
-- , viewSentence model.chosenSentence model.chosenWords
]
, viewHiddenWords (hiddenWords model.sentence)
]
]
]
Back to viewHiddenWords
. It has an inner viewChosenWord
function. Let's rename it to viewHiddenWord
and change the signature, since we now have only HiddenWord
:
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
We don't need the case
anymore for the same reason - we only have HiddenWord
now, so we can delete it:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
isCorrectGuess : Bool
isCorrectGuess =
List.member hiddenWord sentenceWords
className : String
className =
if isCorrectGuess then
"has-text-success"
else
"has-text-grey-light"
in
li []
[ span [ class className ]
[ text wordString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
in
ul [] (List.map viewHiddenWord hiddenWordList)
How do we know that the player's choice is the correct choice? As I previously mentioned, we have all we need in the HiddenWord
type:
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
All we need is to compare the payerChoice
and answer
. They both are container types:
type PlayerChoice
= PlayerChoice String
type Answer
= Answer String
So we need to extract the String
value from them. Let's add playerChoiceToString
in addition to a previously declared answerToString
:
playerChoiceToString : PlayerChoice -> String
playerChoiceToString (PlayerChoice stringValue) =
stringValue
answerToString : Answer -> String
answerToString (Answer stringValue) =
stringValue
Pretty straightforward using the destructuring.
Now we can use them in isCorrectGuess
:
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
isCorrectGuess : Bool
isCorrectGuess =
answerToString hiddenWord.answer == playerChoiceToString hiddenWord.playerChoice
The className
needs no change. But the li
declaration again needs a String
where wordString
was:
li []
[ span [ class className ]
[ text wordString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
What should go there? The String
from the hiddenWord.answer
of course!
li []
[ span [ class className ]
[ text <| answerToString hiddenWord.answer
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
We can refactor it a little to reuse the value of answerToString hiddenWord.answer
by adding 2 declarations:
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
answerString : String
answerString =
answerToString hiddenWord.answer
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
and:
isCorrectGuess : Bool
isCorrectGuess =
answerString == playerChoiceString
-- omitted code
li []
[ span [ class className ]
[ text answerString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
And now our viewHiddenWords
looks like this:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
answerString : String
answerString =
answerToString hiddenWord.answer
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
isCorrectGuess : Bool
isCorrectGuess =
answerString == playerChoiceString
className : String
className =
if isCorrectGuess then
"has-text-success"
else
"has-text-grey-light"
in
li []
[ span [ class className ]
[ text answerString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
in
ul [] (List.map viewHiddenWord hiddenWordList)
Lets look at our game:
Looks good, but we are missing one more detail - the hidden words are presented in the same order as they appear in the sentence... If you remember we added sortKey
to our HiddenWord
to be able to show them in random order:
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
Currently, we have the random order hardcoded in the initialModel
:
initialModel : Model
initialModel =
{ sentence =
[ SentenceWrd "The"
, HiddenWrd
{ sortKey = 3
, answer = Answer "pen"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "is"
, HiddenWrd
{ sortKey = 1
, answer = Answer "mightier"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "than"
, SentenceWrd "the"
, HiddenWrd
{ sortKey = 2
, answer = Answer "sword"
, playerChoice = PlayerChoice ""
}
]
}
So what is left to do is to sort the hidden words by the sortKey
before showing them. We'll add hiddenWordsSorted
and use it :
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
hiddenWordsSorted : List HiddenWord
hiddenWordsSorted =
List.sortBy .sortKey hiddenWordList
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
-- omitted
in
ul [] <| List.map viewHiddenWord hiddenWordsSorted
One last small refactor I would like to do is to wordToString
:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd hiddenWord ->
case hiddenWord.answer of
Answer answerString ->
answerString
We have answerToString
so we can use it in the second branch of the case
:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd hiddenWord ->
answerToString hiddenWord.answer
or even shorter:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd { answer } ->
answerToString answer
The final part - viewSentence
. Let's uncomment it in the view
function.
viewSentence model.chosenSentence model.chosenWords
We don't have chosenSentence
anymore since we replaced sentence
, and we also got rid of the additional chosenWords
. We do need to send the List HiddenWord
to the viewSentence
so it knows what to render as a <select>
element. We already have a helper function for it - hiddenWords
- that we use when calling viewHiddenWords
. So the viewSentence
may look like this:
view : Model -> Html Msg
view model =
-- omitted
[ viewOriginalSentence model.sentence
, viewSentence model.sentence (hiddenWords model.sentence)
]
Looks like we are sending model.sentence
to viewSentence
and using it to get hidden words... We don't need to send (hiddenWords model.sentence)
since viewSentence
can calculate them by itself!
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ viewOriginalSentence model.sentence
, viewSentence model.sentence
]
, viewHiddenWords (hiddenWords model.sentence)
]
]
]
Let's uncomment viewSentence
and change its signature from:
viewSentence : Words -> Words -> Html Msg
viewSentence sentence chosenWords =
to
viewSentence : Words -> Html Msg
viewSentence sentence =
Now we'll obtain hiddenWords:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ =
hiddenWords sentence
in
And update the rest of viewSentence
to:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
span [ class "sentence-word" ] [ text (answerToString hiddenWord.answer) ]
-- viewHiddenWord sentenceWord hiddenWords_
)
sentence
)
I have commented out the call to viewHiddenWord
so we can see the intermediate result:
Now let's uncomment viewHiddenWord
and return the line inside viewSentence
:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
viewHiddenWord sentenceWord hiddenWords_
)
sentence
)
We need to change sentenceWord
to hiddenWord
:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
viewHiddenWord hiddenWord hiddenWords_
)
sentence
)
Good. Now to viewHiddenWord
. Let's update its type signature from:
viewHiddenWord : Word -> List Word -> Html Msg
viewHiddenWord hiddenWord chosenWords =
to
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords =
The only type that viewHiddenWord
can accept now is HiddenWord
. We also renamed all the chosenXYZ
to hiddenXYZ
.
Again, we don't need the case
expression anymore!
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords =
let
viewOption : String -> Html Msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : Word -> Html Msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement hiddenWords
]
Let's also rename the 2 instances of hiddenWords
to hiddenWords_
, since the compiler complains about name collisions (2nd line and the line before the last in the code snippet).
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
viewOption : String -> Html Msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : Word -> Html Msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement hiddenWords_
]
The wordElement
also gets simpler since it should only get HiddenWord
, so the strange-looking case
is gone:
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
viewOption : String -> Html Msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : HiddenWord -> Html Msg
wordElement hiddenWord_ =
viewOption (answerToString hiddenWord_.answer)
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement hiddenWords_
]
The viewOption
change is a pretty simple one. We need to select the <option>
where the answer
from the current HiddenWord
equals playerChoice
from the HiddenWord
that was passed in:
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
viewOption : String -> Html Msg
viewOption answerString =
option
[ value answerString, selected (answerString == playerChoiceString) ]
[ text <| String.toLower answerString ]
wordElement : HiddenWord -> Html Msg
wordElement hiddenWord_ =
viewOption
(answerToString hiddenWord_.answer)
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement hiddenWords_
]
The compiler now complains about name collision between this viewHiddenWord
and inner viewHiddenWord
inside viewHiddenWords
... Let's add _
to the inner viewHiddenWord
:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
hiddenWordsSorted : List HiddenWord
hiddenWordsSorted =
List.sortBy .sortKey hiddenWordList
viewHiddenWord_ : HiddenWord -> Html msg
viewHiddenWord_ hiddenWord =
-- omitted
in
ul [] <| List.map viewHiddenWord_ hiddenWordsSorted
Looking at the code we can see that wordElement
does almost nothing and just calls viewOption
, so we can just remove it and call viewOption
directly:
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
viewOption : HiddenWord -> Html Msg
viewOption hiddenWord_ =
let
answerString =
answerToString hiddenWord_.answer
in
option
[ value answerString, selected (answerString == playerChoiceString) ]
[ text <| String.toLower answerString ]
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map viewOption hiddenWords_
]
We now pass HiddenWord
to viewOption
and calculate answerString
inside it. We also can use destructuring in the viewOption
declaration to get hiddenWord_.answer
immediately:
viewOption : HiddenWord -> Html Msg
viewOption { answer } =
let
answerString : String
answerString =
answerToString answer
in
option
[ value answerString, selected (answerString == playerChoiceString) ]
[ text <| String.toLower answerString ]
We still have hiddenIndex
in our viewHiddenWord
that we haven't handled. This should be the index of the word in the sentence, so we can know what word to update with the player's choice. As we discussed in the beginning of the refactor, we can have this index by using List.indexedMap
instead of List.map
in viewSentence
, and call viewHiddenWord
with that index.
Let's update viewHiddenWord
to use List.indexedMap
, get the index in the inner function and pass it to viewHiddenWord
:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.indexedMap
(\index sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
viewHiddenWord index hiddenWord hiddenWords_
)
sentence
)
Now let's update viewHiddenWord
to take the index as its first argument and use it:
viewHiddenWord : Int -> HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord index hiddenWord hiddenWords_ =
-- omitted
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged index) ] <|
option []
[ text "" ]
:: List.map viewOption hiddenWords_
]
The game compiles and works!
But the <option>
s in the drop down again come in the same order as they appear in the sentence. We still need to sort them by sortKey
as we did in viewHiddenWords
.
We are repeating ourselves with hiddenWordsSorted
, so lets refactor it to its own function:
hiddenWordsSorted : List HiddenWord -> List HiddenWord
hiddenWordsSorted hiddenWordList =
List.sortBy .sortKey hiddenWordList
and use it inside viewHiddenWords
:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
hiddenWordsSorted_ : List HiddenWord
hiddenWordsSorted_ =
hiddenWordsSorted hiddenWordList
--- omitted
in
ul [] <| List.map viewHiddenWord_ hiddenWordsSorted_
and in viewHiddenWord
:
viewHiddenWord : Int -> HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord index hiddenWord hiddenWords_ =
let
hiddenWordsSorted_ : List HiddenWord
hiddenWordsSorted_ =
hiddenWordsSorted hiddenWords_
-- omitted
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged index) ] <|
option []
[ text "" ]
:: List.map viewOption hiddenWordsSorted_
]
And now we have the hidden words in the correct random order also in the drop down:
Phew! That was a lot! And we gained a lot! Much less places to get our code into invalid state.
The current progress is saved in the repo under a Tag v2.0 https://github.com/mickeyvip/words-memory-game/tree/v2.0.
In the next post we will add randomness to select different words for each new game. Stay tuned!
Acknowledgements
Special thanks to Joël Quenneville for reviewing the code and suggesting the refactoring presented in this post.
Posted on April 11, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024