Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

01 March 2016

Tags: haskell elm haskellelmspa

So 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".

Useful resources
  • Check out the other episodes in this blog series.

  • The accompanying Albums sample app is on github, and there is a tag for each episode

Introduction

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.

A highlevel summary of changes includes:
  • 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

Backend

Stack

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.

Datamodel

albums db part4

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.

Bootstrapping

The code for schema creation and bootstrapping test data has been moved to a separate module.

backend/src/Bootstrap.hs
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 (:

New endpoints for Albums

Model additions

backend/src/Model.hs
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 ?

Albums CRUD functions

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.

Adding albums to the API

backend/src/Api.hs
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.
backend/Main.hs
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

Backend summary

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.

Haskell IO

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.

REST concerns

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

Reading about GraphQL, Falcor and more recently Om next has been an eye-opener to me. The ideas here rings true and bodes well for the frontend, probably soonish something will materialize for Elm too. But what to do on the server side I wonder ?

Frontend

New routes

frontend/src/Routes.elm
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.

Service API

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

The album page

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

albumdetails

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.

Types

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.

The update function

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)

The view

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 ?

Track row

Types

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 function

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 !

View

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.

Main.elm wiring it all up

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

Frontend summary

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.

Concluding remarks

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.

Whats next ?

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.

comments powered by Disqus