01 March 2016
Tags: haskell elm haskellelmspa
TweetSo the hypothesis from episode 3 was that it should be relatively easy to add new features. In this episode we’ll put that hypothesis to the test and add CRUD features for Albums. There will be a little refactoring, no testing, premature optimizations and plenty of "let the friendly Elm and Haskell compilers guide us along the way".
When I set out to implement the features for this episode I didn’t really reflect on how I would then later go about blogging about it. It turns out I probably did way to many changes to fit nicely into a blog episode. Let’s just say I got caught up in a coding frenzy, but let me assure you I had a blast coding for this episode ! This means I wont be going into detail about every change I’ve made since the last episode, but rather try to highlight the most important/interesting ones.
Haskell stack has been introduced to the backend
Implemented REST endpoints for Albums CRUD
Backend now composes endpoints for Artists and Albums
Data model changed to account for Album and Track entities
Bootstrapping of sample data extended and refactored to a separate module
Implemented UI for listing, deleting, creating, updating and displaying album details
In particular the the features for creating/updating Albums and associated tracks, gives a glimpse of the compasability powers of the Elm Architecture
Working with Cabal and Cabal sandboxes is a bit of a pain. Stack promises to alleviate some of those pains, so I figured
I’d give it a go. There are probably tutorials/blog posts out there going into how you should go about migrating
to use stack in your Haskell projects, so I won’t go into any details here.
Basically I installed stack and added a stack configuration file stack.yml
. After that I was pretty much up and running.
The instructions for running the sample app with stack can be found in the Albums README.
The datamodel contains a little bit of flexibility so that a track can be potentially be included in many albums (hence the album_track entity). For this episode though, we’re not using that and of course that innocent bit of flexibility comes with a cost of added complexity. I considered removing the album_track entity, but decided against it. I figured that in a real project this is a typical example of things you have to deal with (say you have a DBA or even more relevant… and exisiting datamodel you have to live with). Let’s run with it, and try to deal with it along the way.
The code for schema creation and bootstrapping test data has been moved to a separate module.
bootstrapDB :: Sql.Connection -> IO ()
bootstrapDB conn = do
createSchema conn
populateSampleData conn
createSchema :: Sql.Connection -> IO ()
createSchema conn = do
executeDB "PRAGMA foreign_keys = ON"
executeDB "create table artist (id integer primary key asc, name varchar2(255))"
executeDB "create table track (id integer primary key asc, name varchar2(255), duration integer)"
executeDB "create table album (id integer primary key asc, artist_id integer, name varchar2(255), FOREIGN KEY(artist_id) references artist(id))"
executeDB "create table album_track (track_no integer, album_id, track_id, primary key(track_no, album_id, track_id), foreign key(album_id) references album(id), foreign key(track_id) references track(id))"
where
executeDB = Sql.execute_ conn
-- definition of sample data omitted for brevity
populateSampleData :: Sql.Connection -> IO ()
populateSampleData conn = do
mapM_ insertArtist artists
mapM_ insertTrack tracks
mapM_ insertAlbum albums
mapM_ insertAlbumTrack albumTracks
where
insertArtist a = Sql.execute conn "insert into artist (id, name) values (?, ?)" a
insertTrack t = Sql.execute conn "insert into track (id, name, duration) values (?, ?, ?)" t
insertAlbum a = Sql.execute conn "insert into album (id, artist_id, name) values (?, ?, ?)" a
insertAlbumTrack at = Sql.execute conn "insert into album_track (track_no, album_id, track_id) values (?, ?, ?)" at
Somewhat amusing that foreign key constraints are not turned on by default in SQLite, but hey. What’s less amusing is that foreign key exceptions are very unspecific about which contraints are violated (:
data Track = Track (1)
{ trackId :: Maybe Int
, trackName :: String
, trackDuration :: Int -- seconds
} deriving (Eq, Show, Generic)
data Album = Album (2)
{ albumId :: Maybe Int
, albumName :: String
, albumArtistId :: Int
, albumTracks :: [Track]
} deriving (Eq, Show, Generic)
1 | Our Track type doesn’t care about the distiction between the album and album_track entities |
2 | It was tempting to add Artist as a property to the Album type, but opted for just the id of an Artist entity. I didn’t want to be forced to return a full artist instance for every Album returned. You gotta draw the line somewhere right ? |
In order to keep this blog post from becoming to extensive we’ve only included the functions to list and create new albums. You can view the update, findById and delete functions in the album sample repo
findAlbums :: Sql.Connection -> IO [M.Album] (1)
findAlbums conn = do
rows <- Sql.query_ conn (albumsQuery "") :: IO [(Int, String, Int, Int, String, Int)]
return $ Map.elems $ foldl groupAlbum Map.empty rows
findAlbumsByArtist :: Sql.Connection -> Int -> IO [M.Album] (2)
findAlbumsByArtist conn artistId = do
rows <- Sql.query conn (albumsQuery " where artist_id = ?") (Sql.Only artistId) :: IO [(Int, String, Int, Int, String, Int)]
return $ Map.elems $ foldl groupAlbum Map.empty rows
albumsQuery :: String -> SqlTypes.Query (3)
albumsQuery whereClause =
SqlTypes.Query $ Txt.pack $
"select a.id, a.name, a.artist_id, t.id, t.name, t.duration \
\ from album a inner join album_track at on a.id = at.album_id \
\ inner join track t on at.track_id = t.id"
++ whereClause
++ " order by a.id, at.track_no"
groupAlbum :: Map.Map Int M.Album -> (Int, String, Int, Int, String, Int) -> Map.Map Int M.Album (4)
groupAlbum acc (albumId, albumName, artistId, trackId, trackName, trackDuration) =
case (Map.lookup albumId acc) of
Nothing -> Map.insert albumId (M.Album (Just albumId) albumName artistId [M.Track (Just trackId) trackName trackDuration]) acc
Just _ -> Map.update (\a -> Just (addTrack a (trackId, trackName, trackDuration))) albumId acc
where
addTrack album (trackId, trackName, trackDuration) =
album {M.albumTracks = (M.albumTracks album) ++ [M.Track (Just trackId) trackName trackDuration]}
newAlbum :: Sql.Connection -> M.Album -> IO M.Album (5)
newAlbum conn album = do
Sql.executeNamed conn "insert into album (name, artist_id) values (:name, :artistId)" [":name" := (M.albumName album), ":artistId" := (M.albumArtistId album)]
albumId <- lastInsertRowId conn
tracks <- zipWithM (\t i -> newTrack conn (i, fromIntegral albumId, (M.albumArtistId album), t)) (M.albumTracks album) [0..]
return album { M.albumId = Just $ fromIntegral albumId
, M.albumTracks = tracks
}
newTrack :: Sql.Connection -> (Int, Int, Int, M.Track) -> IO M.Track (6)
newTrack conn (trackNo, albumId, artistId, track) = do
Sql.executeNamed conn "insert into track (name, duration) values (:name, :duration)" [":name" := (M.trackName track), ":duration" := (M.trackDuration track)]
trackId <- lastInsertRowId conn
Sql.execute conn "insert into album_track (track_no, album_id, track_id) values (?, ?, ?)" (trackNo, albumId, trackId)
return track {M.trackId = Just $ fromIntegral trackId}
1 | Function to list all albums |
2 | Function to list albums filtered by artist |
3 | Helper function to construct an album query with an optional where clause. The query returns a product of albums and their tracks. Let’s just call this a performance optimization to avoid n+1 queries :-) |
4 | Since album information is repeated for each track, we need to group tracks per album. This part was a fun challenge for a Haskell noob. I’m sure it could be done eveny more succinct, but I’m reasonably happy with the way it turned out. |
5 | This is the function to create a new album with all it’s tracks. We assume the tracks are sorted in the order they should be persisted and uses zipWith to get a mapIndexed kind of function so that we can generate the appropriate track_no for each album_track in the db. |
6 | Working with tracks we have to consider both the track and album_track entities in the db. As it is, the album_track table is just overhead, but we knew that allready given the design decission taken earlier. Once we need to support the fact that a track can be included in more that one album, we need to rethink this implementation. |
type AlbumAPI = (1)
QueryParam "artistId" Int :> Get '[JSON] [M.Album] (2)
:<|> ReqBody '[JSON] M.Album :> Post '[JSON] M.Album
:<|> Capture "albumId" Int :> ReqBody '[JSON] M.Album :> Put '[JSON] M.Album
:<|> Capture "albumId" Int :> Get '[JSON] M.Album
:<|> Capture "albumId" Int :> Delete '[] ()
albumsServer :: Sql.Connection -> Server AlbumAPI
albumsServer conn =
getAlbums :<|> postAlbum :<|> updateAlbum :<|> getAlbum :<|> deleteAlbum
where
getAlbums artistId = liftIO $ case artistId of (3)
Nothing -> S.findAlbums conn
Just x -> S.findAlbumsByArtist conn x
postAlbum album = liftIO $ Sql.withTransaction conn $ S.newAlbum conn album
updateAlbum albumId album = liftIOMaybeToEither err404 $ Sql.withTransaction conn $ S.updateAlbum conn album albumId
getAlbum albumId = liftIOMaybeToEither err404 $ S.albumById conn albumId
deleteAlbum albumId = liftIO $ Sql.withTransaction conn $ S.deleteAlbum conn albumId
type API = "artists" :> ArtistAPI :<|> "albums" :> AlbumAPI (4)
combinedServer :: Sql.Connection -> Server API (5)
combinedServer conn = artistsServer conn :<|> albumsServer conn
1 | We’ve added a new API type for Albums |
2 | For listing albums we support an optional query param to allow us to filter albums by artist |
3 | This implementation is quite simplistic, we probably want to provide a more generic way to handle multiple filter criteria in the future. |
4 | The API for our backend is now a composition of the api for artists and the api for albums |
5 | As Servant allows us to compose apis it also allows us to compose servers (ie the implementations of the apis). We create a combined server, which is what we ultimately expose from our backend server |
The really observant reader might have noticed that the update function for albums is a little bit more restrictive/solid than the corresponding function for artist. Here we actually check if the given album id corresponds to a album in the DB. If it doesn’t we return a 404. |
app :: Sql.Connection -> Application
app conn = serve A.api (A.combinedServer conn) (1)
main :: IO ()
main = do
withTestConnection $ \conn -> do
B.bootstrapDB conn (2)
run 8081 $ albumCors $ app conn
1 | Rather than serve the just the albumServer, we now serve the combined server. |
2 | We’ve updated bootstrapping to use the the new bootstrap module |
That wasn’t to hard now was it ? Adding additional end points was quite straightforward, the hard part was overcoming analysis paralysis. Settling on data types and db design took some time, and in hindsight I might have opted for a more simplistic db design. I’m also curious about how the design would have been had I started top down (frontend first) and backend last. I have a strong suspicion it would have been different.
The thing I probably spent most time struggling with was working with IO actions. Apparantly I shouldn’t
use the term IO Monad. Anyways I can’t wrap my head around
when I’m "inside" the IO thingie and when I’m not. It’s obvious that do
, ←
, let
and return
is something
I have to sit down and understand (in the context of IO things). My strategy of trial and error doesn’t scale
all that well, and whatsmore It feels ackward not having a clue on the reasoning on why something is working or not.
Note to self, read up on Haskell IO.
Even with this simple example I started to run into the same old beef I have with generic rest endpoints. They rarely fit nicely with a Single Page Application. They work ok when it comes to adding and updating data, but when it comes to querying it all becomes much more limiting. In a SPA you typically want much more flexibility in terms of what you query by and what you get in return.
In an album listing for a given artist I might just want to display the name, release date, number of songs and album length I’m not interested in the tracks.
In an album listing / album search outside of an artist context I probably want to display the artist name
For a mobile client I might just want to display the album name (size of payloads might actually be important for mobile…)
Likewise when listing artists I might want to display number of albums
Or when searching I might want to search album name, artist name and/or track name
type Route (1)
= Home
-- ...
| AlbumDetailPage Int
| NewArtistAlbumPage Int
| EmptyRoute
routeParsers = (2)
[ static Home "/"
-- ...
, dyn1 AlbumDetailPage "/albums/" int ""
, dyn1 NewArtistAlbumPage "/artists/" int "/albums/new"
]
encode route = (3)
case route of
Home -> "/"
-- ...
AlbumDetailPage i -> "/albums/" ++ toString i
NewArtistAlbumPage i -> "/artists/" ++ (toString i) ++ "/albums/new"
EmptyRoute -> ""
1 | We have added 2 new routes, one for edit/create albums, one for creating a new album (for a given artist) (actually there is a 3 for creating an album without selecting an artist, but it’s not wired up yet) |
2 | We need to add route matchers for the new routes. |
3 | We also need to add encoders for our new routes. |
To call our new REST api for albums we need to implement a few new functions and json decoders. We’ll only show two of the api related functions.
type alias AlbumRequest a = (1)
{ a | name : String
, artistId : Int
, tracks : List Track
}
type alias Album = (2)
{ id : Int
, name : String
, artistId : Int
, tracks : List Track
}
type alias Track = (3)
{ name : String
, duration : Int
}
getAlbumsByArtist : Int -> (Maybe (List Album) -> a) -> Effects a (4)
getAlbumsByArtist artistId action =
Http.get albumsDecoder (baseUrl ++ "/albums?artistId=" ++ toString artistId)
|> Task.toMaybe
|> Task.map action
|> Effects.task
createAlbum : AlbumRequest a -> (Maybe Album -> b) -> Effects.Effects b (5)
createAlbum album action =
Http.send Http.defaultSettings
{ verb = "POST"
, url = baseUrl ++ "/albums"
, body = Http.string (encodeAlbum album)
, headers = [("Content-Type", "application/json")]
}
|> Http.fromJson albumDecoder
|> Task.toMaybe
|> Task.map action
|> Effects.task
-- other functions left out for brevity. Check out the sample code or have a look at episode 2 for inspiration
-- Decoders/encoders for albums/tracks (6)
albumsDecoder : JsonD.Decoder (List Album)
albumsDecoder =
JsonD.list albumDecoder
albumDecoder : JsonD.Decoder Album
albumDecoder =
JsonD.object4 Album
("albumId" := JsonD.int)
("albumName" := JsonD.string)
("albumArtistId" := JsonD.int)
("albumTracks" := JsonD.list trackDecoder)
trackDecoder : JsonD.Decoder Track
trackDecoder =
JsonD.object2 Track
("trackName" := JsonD.string)
("trackDuration" := JsonD.int)
encodeAlbum : AlbumRequest a -> String
encodeAlbum album =
JsonE.encode 0 <|
JsonE.object
[ ("albumName", JsonE.string album.name)
, ("albumArtistId", JsonE.int album.artistId)
, ("albumTracks", JsonE.list <| List.map encodeTrack album.tracks)
]
encodeTrack : Track -> JsonE.Value
encodeTrack track =
JsonE.object
[ ("trackName", JsonE.string track.name)
, ("trackDuration", JsonE.int track.duration)
]
1 | We use the AlbumRequest type when dealing with new albums |
2 | The Album type represents a persisted album |
3 | We aren’t really interested in the id of tracks so we only need one Track type |
4 | For finding albums for an artist we can use the Http.get function with default settings |
5 | To implement createAlbum we need to use Http.Send so that we can provide custom settings |
6 | Decoding/Encoding Json to/from types isn’t particularily difficult, but it is a bit of boilerplate involved |
We’ve made some changes to the ArtistDetail page which we won’t show in this episode. These changes include:
List all albums for an artist
Add features to remove album and link from each album in listin to edit the album
A button to initation the Album detail page in "Create New" mode
We consider an Album and it’s tracks to be an aggregate. This is also reflected in the implementation of the ArlbumDetail module in the frontend code. You’ll hopefully see that it’s not that hard to implement a semi advanced page by using the composability of the elm architecture.
Ok lets look at how we’ve implemented the Album detail page and it’s associated track listing.
type alias Model = (1)
{ id : Maybe Int
, artistId : Maybe Int
, name : String
, tracks : List ( TrackRowId, TrackRow.Model )
, nextTrackRowId : TrackRowId
, artists : List Artist
}
type alias TrackRowId = (2)
Int
type Action (3)
= NoOp
| GetAlbum (Int)
| ShowAlbum (Maybe Album)
| HandleArtistsRetrieved (Maybe (List Artist))
| SetAlbumName (String)
| SaveAlbum
| HandleSaved (Maybe Album)
| ModifyTrack TrackRowId TrackRow.Action
| RemoveTrack TrackRowId
| MoveTrackUp TrackRowId
| MoveTrackDown TrackRowId
1 | The model kind of reflects the Album type we saw in the previous chapter, but it’s bespoke for use in this view. Most notably we keep a list of Artists (for an artist dropdown) and tracks are represented as a list of trackrow models from the TrackRow.elm module. |
2 | To be able to forward updates to the appropriate TrackRow instance we are using a sequence type |
3 | There are quite a few actions, But the last 4 are related to the list of TrackRows. |
AlbumDetails can be seen as holding an AlbumListing, updates that concerns the list is handled by AlbumDetails whilst updates that concerns individual TrackRows are forwarded to the appropriate TrackRow instance.
update : Action -> Model -> ( Model, Effects Action )
update action model =
case action of
NoOp ->
( model, Effects.none )
GetAlbum id -> (1)
( model
, Effects.batch
[ getAlbum id ShowAlbum
, getArtists HandleArtistsRetrieved
]
)
ShowAlbum maybeAlbum -> (2)
case maybeAlbum of
Just album ->
( createAlbumModel model album, Effects.none )
-- TODO: This could be an error if returned from api !
Nothing ->
( maybeAddPristine model, getArtists HandleArtistsRetrieved )
HandleArtistsRetrieved xs -> (3)
( { model | artists = (Maybe.withDefault [] xs) }
, Effects.none
)
SetAlbumName txt -> (4)
( { model | name = txt }
, Effects.none
)
SaveAlbum -> (5)
case (model.id, model.artistId) of
(Just albumId, Just artistId) ->
( model
, updateAlbum (Album albumId model.name artistId (createTracks model.tracks)) HandleSaved
)
(Nothing, Just artistId) ->
( model
, createAlbum { name = model.name
, artistId = artistId
, tracks = (createTracks model.tracks)
} HandleSaved
)
(_, _) ->
Debug.crash "Missing artist.id, needs to be handled by validation"
HandleSaved maybeAlbum -> (6)
case maybeAlbum of
Just album ->
( createAlbumModel model album
, Effects.map (\_ -> NoOp) (Routes.redirect <| Routes.ArtistDetailPage album.artistId)
)
Nothing ->
Debug.crash "Save failed... we're not handling it..."
RemoveTrack id -> (7)
( { model | tracks = List.filter (\( rowId, _ ) -> rowId /= id) model.tracks }
, Effects.none
)
MoveTrackUp id -> (8)
let
track =
ListX.find (\( rowId, _ ) -> rowId == id) model.tracks
in
case track of
Nothing ->
( model, Effects.none )
Just t ->
( { model | tracks = moveUp model.tracks t }
, Effects.none
)
MoveTrackDown id -> (9)
let
track =
ListX.find (\( rowId, _ ) -> rowId == id) model.tracks
mayMoveDown t =
let
idx =
ListX.elemIndex t model.tracks
in
case idx of
Nothing ->
False
Just i ->
i < ((List.length model.tracks) - 2)
in
case track of
Nothing ->
( model, Effects.none )
Just t ->
( { model
| tracks =
if (mayMoveDown t) then
moveDown model.tracks t
else
model.tracks
}
, Effects.none
)
ModifyTrack id trackRowAction -> (10)
let
updateTrack ( trackId, trackModel ) =
if trackId == id then
( trackId, TrackRow.update trackRowAction trackModel )
else
( trackId, trackModel )
in
( maybeAddPristine { model | tracks = List.map updateTrack model.tracks }
, Effects.none
)
1 | When we mount the route for an existing album, we need to retrieve both the album and
all artists (for the artist dropdown). To do both in one go we can use Effects.batch |
2 | We use the album param to differntiate between "update" and "new" mode for albums. If show album is called with an album we update our inital model with the information contained in the given album (this also involves initating TrackRow.models for each album track. If there is no album, we just add an empty track row and the initiate the retrieval of artists for the artists dropdown. |
3 | Once artists are retrieved we update our model to hold these |
4 | This action is executed when the user changes the value of the name field |
5 | The save action either calls update or create in the server api based on whether the model has an albumId or not. In both instances it needs to convert the model to an Album/AlbumRequest as this is what the signature of the ServerApi functions require |
6 | A successful save will give an Album type back, we update the model and in this instance we also redirect the user to the artist detail page. |
7 | This action is called when the user clicks on the remove button for a track row. We’ll get back to this when in just a little while |
8 | Action to move a track one step up in the track listing. If it’s already at the top
it’s a no op. The "heavy" lifting is done in the moveUp generic helper function |
9 | Similar to MoveTrackUp but it has addtional logic to ensure we don’t move a track below the
always present empty (Pristine) row in the track listing |
10 | The ModifyTrack action forwards to the update function for the TrackRow in question. Each track row is tagged with an Id (TrackRowId) |
view : Signal.Address Action -> Model -> Html (1)
view address model =
div
[]
[ h1 [] [ text <| pageTitle model ]
, Html.form
[ class "form-horizontal" ]
[ div
[ class "form-group" ]
[ label [ class "col-sm-2 control-label" ] [ text "Name" ]
, div
[ class "col-sm-10" ]
[ input
[ class "form-control"
, value model.name
, on "input" targetValue (\str -> Signal.message address (SetAlbumName str))
]
[]
]
]
, ( artistDropDown address model )
, div
[ class "form-group" ]
[ div
[ class "col-sm-offset-2 col-sm-10" ]
[ button
[ class "btn btn-default"
, type' "button"
, onClick address SaveAlbum
]
[ text "Save" ]
]
]
]
, h2 [] [ text "Tracks" ]
, trackListing address model
]
artistDropDown : Signal.Address Action -> Model -> Html (2)
artistDropDown address model =
let
val =
Maybe.withDefault (-1) model.artistId
opt a =
option [ value <| toString a.id, selected (a.id == val) ] [ text a.name ]
in
div
[ class "form-group" ]
[ label [ class "col-sm-2 control-label" ] [ text "Artist" ]
, div
[ class "col-sm-10" ]
[ select
[ class "form-control" ]
(List.map opt model.artists)
]
]
trackListing : Signal.Address Action -> Model -> Html (3)
trackListing address model =
table
[ class "table table-striped" ]
[ thead
[]
[ tr
[]
[ th [] []
, th [] []
, th [] [ text "Name" ]
, th [] [ text "Duration" ]
, th [] []
]
]
, tbody [] (List.map (trackRow address) model.tracks)
]
trackRow : Signal.Address Action -> ( TrackRowId, TrackRow.Model ) -> Html (4)
trackRow address ( id, rowModel ) =
let
context =
TrackRow.Context
(Signal.forwardTo address (ModifyTrack id))
(Signal.forwardTo address (always (RemoveTrack id)))
(Signal.forwardTo address (always (MoveTrackUp id)))
(Signal.forwardTo address (always (MoveTrackDown id)))
in
TrackRow.view context rowModel
1 | The view function for the page. |
2 | The artist dropdown (a github star for the observant reader that can spot what’s missing :-) ) |
3 | Generates the track listing for the album |
4 | The rendering of each individual TrackRow is forwarded to the TrackRow module. We pass on a context so that a TrackRow is able to "signal back" to the AlbumDetails page for the actions that are owned by AlbumDetails (RemoveTrack, MoveTrackUp and MoveTrackDown). You’ll see how that plays out when we look at the TrackRow implementation in the next secion. |
Why the context thingie ? Well we can’t have the AlbumDetails depending on TrackRows and the TrackRow component having a dependency back to AlbumDetails. To solve that we pass on the tagged forwarding addresses so that TrackRows can signal AlbumDetails with the appropriate actions. I guess you can sort of think of them as callbacks, but it’s not quite that. Another slightly more elaborate explantion might be that when a user performs something on a track row that we capture (say a click on the remove button). The view from the track row returns a signal (wrapped as an effect) to album details which in turn returns a signal back to main. The signal is processed by the startapp "event-loop" and flows back through the update functions (main → AlbumDetails) and since it’s tagged to as an action to be handled by AlbumDetails is handled in AlbumDetails update function (and doesn’t flow further. Clear as mud or perhaps it makes sort of sense ? |
type alias Model = (1)
{ name : String
, durationMin : Maybe Int
, durationSec : Maybe Int
, status : Status
}
type alias Context = (2)
{ actions : Signal.Address Action
, remove : Signal.Address ()
, moveUp : Signal.Address ()
, moveDown : Signal.Address ()
}
type Status (3)
= Saved
| Modified
| Error
| Pristine (4)
type Action (5)
= SetTrackName String
| SetMinutes String
| SetSeconds String
1 | The model captures information about an album track. Duration is separated into minutes and seconds to be more presentable and easier for the user to input. In addition we have a status flag to be able to give the user feedback and handle some conditional logic. |
2 | Here you see the type definition for the Context we previously mentioned we used in the when forwarding view rendering for each individual track row in the Album Details page. (Btw it could be any component as long as they pass on a context with the given signature of Context). |
3 | The possible status types a row can be in. |
4 | Prisitine has a special meaning in the track listing in AlbumDetails. It should always be just one and it should be the last row. However that’s not the responsibility of TrackRow. TrackRow should just ensure the status is correct at all times. |
5 | The possible actions that TrackRow handles internally |
update : Action -> Model -> Model
update action model =
case action of
SetTrackName v -> (1)
{ model | name = v, status = Modified }
SetMinutes str -> (2)
let
maybeMinutes = Result.toMaybe <| String.toInt str
in
case maybeMinutes of
Just m ->
{ model | durationMin = maybeMinutes, status = Modified }
Nothing ->
if String.isEmpty str then
{ model | durationMin = Nothing, status = Modified}
else
model
SetSeconds str -> (3)
let
maybeSeconds = Result.toMaybe <| String.toInt str
in
case maybeSeconds of
Just m ->
if m < 60 then
{ model | durationSec = maybeSeconds, status = Modified }
else
model
Nothing ->
if String.isEmpty str then
{ model | durationSec = Nothing, status = Modified}
else
model
1 | Updates the trackname model property when user inputs into the trackname field |
2 | Updates the minutes property if a valid number is entered. Also blanks the field when the text input field becomes empty |
3 | Similar to minutes, but also ensures that you don’t enter more than 59 ! |
We’ll only show parts of the view to limit the amount of code you need to scan through.
view : Context -> Model -> Html
view context model =
tr
[]
[ td [] [ statusView model ]
, td [] [ moveView context model ]
, td [] [ nameView context model ]
, td [] [ durationView context model ]
, td [] [ removeView context model ]
]
nameView : Context -> Model -> Html
nameView context model =
input
[ class "form-control"
, value model.name
, on "input" targetValue (\str -> Signal.message context.actions (SetTrackName str)) (1)
]
[]
removeView : Context -> Model -> Html
removeView context model =
button
[ onClick context.remove () (2)
, class <| "btn btn-sm btn-danger " ++ if isPristine model then "disabled" else ""
]
[ text "Remove" ]
1 | When a user causes an input event on the name input field we create a message using the address in context.actions with action SetTrackName So this message will cause an update eventually forwarded to the update function of TrackRow |
2 | When a user clicks on the remove button we use the address given by context.remove with a payload of () (ie void).
This message will always be forwarded to the address for AlbumDetails with the payload set to RemoveTrack with the given track row id.
All of which TrackRow is blissfully unaware of. |
type alias Model =
WithRoute
Routes.Route
{ --....
, albumDetailModel : AlbumDetail.Model
}
type Action
= NoOp
-- ...
| AlbumDetailAction AlbumDetail.Action
| RouterAction (TransitRouter.Action Routes.Route)
initialModel =
{ transitRouter = TransitRouter.empty Routes.EmptyRoute
-- ...
, albumDetailModel = AlbumDetail.init
}
mountRoute prevRoute route model =
case route of
-- ...
AlbumDetailPage albumId -> (1)
let
(model', effects) =
AlbumDetail.update (AlbumDetail.GetAlbum albumId) AlbumDetail.init
in
( { model | albumDetailModel = model' }
, Effects.map AlbumDetailAction effects)
NewArtistAlbumPage artistId -> (2)
let
(model', effects) =
AlbumDetail.update (AlbumDetail.ShowAlbum Nothing) (AlbumDetail.initForArtist artistId)
in
( { model | albumDetailModel = model' }
, Effects.map AlbumDetailAction effects)
-- ...
update action model =
case action of
-- ..
AlbumDetailAction act -> (3)
let
( model', effects ) =
AlbumDetail.update act model.albumDetailModel
in
( { model | albumDetailModel = model' }
, Effects.map AlbumDetailAction effects
)
-- ..
1 | When we mount the route for the AlbumDetailsPage ("/albums/:albumId") we call the
update function of AlbuDetail with a GetAlbum action. You might remember that this in turn calls the functions
for retrieving an Album and the function for retrieving artists as a batch. |
2 | When the user performs an action that results in the NewArtistAlbumPage being mounted ("/artists/:artistId/albums/new")
, we call the update on AlbumDetail with ShowAlbum action and a reinitialized model where artistId is set. |
3 | In the update function of Main we forward any actions particular to AlbumDetail |
Working with the frontend code in Elm has been mostly plain sailing. I struggled a bit to get all my ducks(/effects) in a row and I’m not too happy with some of the interactions related to new vs update.
Unfortunately the elm-reactor isn’t working all that well with 0.16, certainly not on my machine. It also doesn’t work particularily well with single page apps that changes the url. I looked at and tried a couple of alternatives and settled on using elm-server. I had to make some modifications to make it work nicely with an SPA. I submitted a PR that seems to work nicely for my use case atleast. With that in place, the roundtrip from change to feedback became very schneizz indeed !
Undoubtably there is quite a bit that feels like boiler plate. The addition of routing also introduces yet another thing you have to keep in mind in several places. Boilerplate it might be, but it’s also quite explicit. I would imagine that in a large app you might grow a bit weary of some of the boilerplate and start looking for ways to reduce it.
I’d be lying if I said I’ve fully grasped; signals, tasks, ports, effects and mailboxes. But it’s gradually becoming clearer and it’s very nice that you can produce pretty cool things without investing to much up front.
I utterly failed to make a shorter blog post yet again. To my defence, the default formatting of Elm do favor newlines bigtime. Most of the Elm code has been formatted by elm-format btw.
I’m really starting to see the benefits of statically (strongly) typed functional languages. The journey so far has been a massive learing experience. Heck this stuff has been so much fun, I ended up taking a day off work so that I could work on this for a whole day with most of my good brain cells still at acceptable performance levels. Shame I can’t use this stuff at work, but I’m starting to accumulate quite a substantial collection of selling points.
The sample app has started to accumulate quite a bit of technical dept, so I suppose the next episode(s) should start to address some of that.