This post belongs to the Phoenix and Elm, a real use case series.
- Introduction to creating a SPA with Phoenix and Elm
- Rendering the initial contact list
- Adding full text search and pagination navigation to the contact list
- Better state with union types, search resetting and keyed nodes.
- Implementing Elm routing
- Poenix and Elm communication through WebSockets
Better states with union types and search resetting
In the previous part, we finally implemented both the pagination navigation and the full text search. We also mentioned that we still have room for some improvements, especially when rendering the list of contacts for the first time, as it is rendering the No contacts found warning message before requesting and displaying the first page. This behavior is incorrect because when a user does a contact search, it has to render that message in just in case no matches are found. The problem is that with the current model that we have, our Elm program does not know how to differentiate whether the contact list is empty due to the first rendering or because of a search with no matches. Therefore, we have to help it somehow to know what is going on and render the necessary stuff only when needed.
Better states with union types
To prevent displaying the message once the program renders for the first time we can check whether
the search is empty or not. If it is empty, it means that the user has not done any search and we
can avoid displaying the message. However, this does not seem like the proper way of doing it, and
if we start adding this small hacks all over the place, our application is going to be less easy
to mantain and scale once it starts to grow. Thankfully, Elm offers us something that fits perfectly
for this case, union types, and after reading this post
from Kris Jenkins, the solution seems pretty simple as well as brilliant.
Let's update the Model
to reflect what we need:
-- web/elm/Model.elm
module Model exposing (..)
type RemoteData e a
= NotRequested
| Requesting
| Failure e
| Success a
type alias Model =
{ contactList : RemoteData String ContactList
, search : String
}
-- ...
initialModel : Model
initialModel =
{ contactList = NotRequested
, search = ""
}
We have created a new type called RemoteData e a
which can have the following values:
NotRequested
which means that the contact list is not being fetched yet so we can distinguish between an initial load and a proper search request from the user.Requesting
, which means that there is a page fetch going on.Failure a
, which means that the request ends up in an error.Success a
, which indicates that everything went fine and includes the current page of results.
In the main Model
, now instead of contactList
being a ContactList
, let's change it for a RemoteData
type, and initialize it with NotRequested
.
-- web/elm/Update.elm
module Update exposing (..)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchResult (Ok response) ->
{ model | contactList = Success response } ! []
FetchResult (Err error) ->
{ model | contactList = Failure "Something went wrong..." } ! []
-- ...
HandleFormSubmit ->
{ model | contactList = Requesting } ! [ fetch 1 model.search ]
-- ...
Cool! Now the update
function is returning a model more aligned with what we need, where we set the Success
and Error
responses depending on the result from the Http request and Requesting
when a new search is
submitted. Let's edit the ContactList.View
module to fix the remaining errors and adapt it to the new model:
-- web/elm/ContactList/View.elm
module ContactList.View exposing (indexView)
-- ...
indexView : Model -> Html Msg
indexView model =
div
[ id "home_index" ]
(viewContent model)
viewContent : Model -> List (Html Msg)
viewContent model =
case model.contactList of
NotRequested ->
[ text "" ]
Requesting ->
[ searchSection model
, warningMessage
"fa fa-spin fa-cog fa-2x fa-fw"
"Searching for contacts"
(text "")
]
Failure error ->
[ warningMessage
"fa fa-meh-o fa-stack-2x"
error
(text "")
]
Success page ->
[ searchSection model
, paginationList page
, div
[]
[ contactsList model page ]
, paginationList page
]
-- ...
warningMessage : String -> String -> Html Msg -> Html Msg
warningMessage iconClasses message content =
div
[ class "warning" ]
[ span
[ class "fa-stack" ]
[ i [ class iconClasses ] [] ]
, h4
[]
[ text message ]
, content
]
Instead of rendering everything in the indexView
function, we have created a new viewContent
function
which has the logic for returning the needed Html depending on the model's contactList
. Therefore,
if its value is NotRequested
(the application just rendered for the first time), it does not render any
Html at all, preventing displaying the unnecessary warning message when no contacts are found.
On the other hand, Requesting
renders a loading message, Failure error
renders the error message,
and finally, Success page
renders what we already implemented in the last post. For simplicity's sake,
I have omitted some of the minor changes, but you can check the final version of the file here.
After refreshing the browser, we can check that the initial warning message is gone and if we do a search,
the loading message gets displayed before receiving any response from the back-end:
Resetting the search
The load and search of contacts look much better, but there is still something missing. If the user wants to reset the search to display the first page of results again, the only way possible of doing it is emptying the search input and pressing intro. This is not very user-friendly, so let's add a couple of handy reset buttons:
-- web/elm/ContactList/View.elm
module ContactList.View exposing (indexView)
-- ...
searchSection : Model -> Html Msg
searchSection model =
div
[ class "filter-wrapper" ]
[ -- ...
, div
[ class "form-wrapper" ]
[ Html.form
[ onSubmit HandleFormSubmit ]
[ resetButton model "reset"
-- ...
]
]
]
-- ...
contactsList : Model -> ContactList -> Html Msg
contactsList model page =
if page.total_entries > 0 then
page.entries
|> List.map contactView
|> div [ class "cards-wrapper" ]
else
warningMessage
"fa fa-meh-o fa-stack-2x"
"No contacts found..."
(resetButton model "btn")
-- ...
resetButton : Model -> String -> Html Msg
resetButton model className =
let
hide =
(String.length model.search) < 1
classes =
classList
[ ( className, True )
, ( "hidden", hide )
]
in
a
[ classes
, onClick ResetSearch
]
[ text "Reset search" ]
We appending the first one floating over the search input, so it is easy for the user to click it,
and the second one in the warning message displayed when no matches are found. In the resetButton
,
it is worth mentioning that we are adding a hidden
class to the button when the search string is not empty,
so it is not displayed until the user types anything. The onClick
event handler sends a ResetSearch
message that we need to add to the Messages
module:
-- web/elm/Messages.elm
module Messages exposing (..)
-- ...
type Msg
= FetchResult (Result Http.Error ContactList)
-- ...
| ResetSearch
Finally, we have to implement the ResetSearch
case in the update function:
-- web/elm/Update.elm
module Update exposing (..)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- ...
ResetSearch ->
{ model | search = "" } ! [ fetch 1 "" ]
Its implementation is very simple; it resets the model's search value and requests another fetch, rendering the first page of contacts:
Extra bonus, Html keyed nodes
During the search and pagination, Elm renders the different contact cards using its internal
diffing algorithm, which in some particular cases, where node children are added, updated,
removed or whatever, can cause unwanted rendering issues. If you are familiar with React,
then you should probably know that for preventing these problems, children elements in a
list must have the key
attribute, which identifies them among their siblings. Elm has a
similar mechanism, in the Html.Keyed package,
so let's take a closer look at it and refactor the contact list, helping Elm with the rendering:
-- web/elm/ContactList/View.elm
module ContactList.View exposing (indexView)
import Contact.View exposing (contactView)
-- ...
import Html.Keyed exposing (..)
-- ...
contactsList : Model -> ContactList -> Html Msg
contactsList model page =
if page.total_entries > 0 then
page.entries
|> List.map contactView
|> Html.Keyed.node "div" [ class "cards-wrapper" ]
else
-- ...
The implementation is very simple as we only need to wrap the list of contactViews
in a Html.Keyed.node "div"
.
Checking the compiler messages we can see a new error, thrown because any keyed parent is expecting a
list of (String, Html msg)
tuples instead of a list of Html msg
. The fist String
element in the tuple is the unique
identifier of the child, so let's edit the contactView
function to solve it:
-- web/elm/Contact/View.elm
module Contact.View exposing (..)
-- ...
contactView : Contact -> ( String, Html Msg )
contactView model =
let
classes =
classList
[ ( "card", True )
, ( "male", model.gender == 0 )
, ( "female", model.gender == 1 )
]
-- ...
in
( toString model.id
, div
[ classes ]
-- ...
)
And with this small change, the compilation error is gone, and the application renders more efficiently. This is all in regards to the search and pagination of contacts, in the next part we will start implementing Elm routing, so a user can navigate to the contact's details page once clicking on its card and back to the list again. In the meantime, check out the branch I have prepared with everything we have done in this part.
Happy coding!