API Design reusable modules

Hello everyone. I’m making my first elm modules that are going to be re-used across different pages. I wanted to follow Evan’s API design of sortable-table, but is hard for me to think a clear way on how to separate the view and the data, and there is not a lot of information on internet (or I haven’t found) on how to think about designing modules this way.

I want to make a Filters Module. Right now I have everything in the Model and I don’t know if is good/wrong and if I should split some parts from model and just pass it to the view like this?

module DataTable.Filter exposing ...

type Model
    = Model Internals

type alias Internals =
    { expanded : Bool
    , filters : OrderedDict String Filter
    , currentDate : Date
    }

type Filter
    = ValueFilter FilterableByValue
    | RangeFilter FilterableByRange

init : List ( String, Filter ) -> ( Model, Cmd Msg )
init filters =
    ( Model <|
        { expanded = False
        , filters = OrderedDict.fromList filters
        , currentDate = initialDate
        }
    , Task.perform GotCurrentDate Date.today
    )

view : Model -> Html Msg
view (Model internals) =
    div [ class "row" ]
        [ ... ]

update : Msg -> Model -> ( Model, ExternalMsg )
update msg (Model internals) =
    case msg of
        ...

I used it in other pages like this:

type alias Model =
    { session : Session
    ...
    , filters : Filter.Model
    , pagination : Pagination.Model
    }

init : Session -> ( Model, Cmd Msg )
init session =
    let
        ( filterModel, filterCmd ) =
            Filter.init
                [ Filter.byText "Account Number"
                ...
                , Filter.byDate "Created At"
                    |> Filter.withDynamicType
                ]

        dataTable =
            DataTable.init

        pagination =
            Pagination.init
    in
    ( { session = session
      ...
      , filters = filterModel
      , pagination = pagination
      }
    , Cmd.batch
        [ Cmd.map FilterMsg filterCmd
        -- TODO: Load Bills
        ]
    )

view : Model -> { title : String, content : Html Msg }
view model =
    { title = "Bills"
    , content =
        case Session.viewer model.session of
            Nothing ->
                text "Sign in to view the Bills."

            _ ->
                div [ class "dataTables_wrapper" ]
                    [ -- Filters
                      Html.map FilterMsg <| lazy Filter.view model.filters
                    , -- DataTable
                      viewDataTable model.bills model.dataTable
                    , -- Pagination
                      if RemoteData.isSuccess model.bills then
                        Pagination.view False model.pagination

                      else
                        Pagination.view True model.pagination
                    ]
    }

I can post more information about the module if needed. I have also another module called DataTable.Pagination and I have the same question as Filters module

From what I gathered on the API design of sortable-table and what I end up doing most of the time in my own reusable views nowadays, the view function for your specific ‘’‘component’’’ (for lack of a better word) should take three parameters:

  1. a Config data type, that carries any customization you wish to enable on said view (rendering attributes, type of messages fired, functions that transform the data provided to be usable by the component, etc…). This shouldn’t be in your Model.
  2. a State data type that resides in your Model and describes every possible State that your view might be in (pagination data, which sort or filter is applied, what is selected, what is not, what is rendered, what is hiddent, etc…)
  3. the data that comes from your Model and needs to be rendered. This can be any data type (provided you gave the instruction on how to process it to the Config object.

Of course, some views are simple enough that they don’t need to worry about some kind of internal State and in that case, you can cut down on that specific parameter.

But, and that is really key here, your data, as stored in your Model can be of any shape you want for storage purposes. It is the Config object responsability to know how to transform that data to a workable shape for rendering.

Your Model should only describe in a very high level fashion what is your data and how it should be displayed. (eg: “my data is a big big list of adresses and contact info and I’d like it sometimes filtered by some field, and sometimes sorted, and maybe I want to be able to pick a background color for my address book”).
The big list is your data, and the State object stores which field is filtered on what value and whether it is reversed or not, sorted or not and the color picked by your user.

The Config's responsability is to know how to go from that high level description, to the actual filtered/sorted/reversed/colored intermediate data and the view just renders that !

Hope that makes sense :slight_smile:

1 Like

As I understand, your Filter contains two types of Model:

  1. A model which describes the state of the filter expanded and filters
  2. A model which is global currentDate

To have the first type model in the Filter is fine. Because these fields will be different for every filter and they don’t depend on another model. But the 2nd type model (currentDate) does not belong in there. Because every filter will have the same date, so if the date changes you will have to update all the filters. For the same reason, you don’t want to keep the Data to be filtered in the filter itself, because if the data will change you will have to remember to update the data in the filter as well, otherwise your filter’s data and global data will be out of sync. So when you design a model, make sure that there are no dependencies between those two.

Also, no Model module should have Msg reference in it. Because you will create circled module dependencies. E.g. So if you have a separate Msg module and you want to create a Msg which updates a list of filters you won’t be able to do so, because a Msg module would require a reference to type Filter. So to avoid this create separate modules for Messages, Model, Update and Views. This is how your modules should depend on each other:

  1. Models depend only on other Models
  2. Views depend on Model, Messages and other Views.
  3. Messages depend only on Models
  4. Updates depend only on Messages and Models

With this knowledge, we can redesign your module API.

    module Filter exposing ...

    type Model
        = Model Internals

    type alias Internals =
        { expanded : Bool
        , filters : OrderedDict String Filter
        }

    type Filter
        = ValueFilter FilterableByValue
        | RangeFilter FilterableByRange

    init : Model
    init = Model <|
            { expanded = False
            , filters = OrderedDict.empty
            }

    -- other functions transforming a Filter Model

Changes:

  • Removed global current date model
  • Removed Cmd msg from init because it contains a reference to the Msg
  • Removed view and update for the same reason.
  • Removed initial filters for the init. Models which has default values are much more flexible, simpler and easier to use.

    module Pagination exposing ...

    type Model
        = Model Internals

    type alias Internals =
        { currentPage : Int
        , itemsPerPage: Int
        }

    init :  Model
    init = 
        Model <| 
            { currentPage = 1
            , itemsPerPage = 20
            }

   nextPage: Model -> Model
   nextPage (Model {currentPage}) = 
       Model {currentPage = currentPage + 1}

    -- other functions for pagination

Changes

  • Pagination model doesn’t contain any data dependencies as well just it’s own state. I would not put even totalPages in it, because total pages depends on the size of your data, so if it would change then you would need to update that as well (no good). Better is to create a function totalPages : List a -> Int and use it in your Views.

    module Table exposing ...

    type Model
        = Model Internals

    type alias Internals =
        { filter : Filter
        , pagination: Pagination
        }

    init : Model
    init = 
        Model <| 
            { filter = Filter.init 
            , pagination = Pagination.init
            }

Page

  • Removed Filter and Pagination references in Session, for the same reasons outlined above. Session is a global data, while Pagination and Filter is not.
  • Put those two in the new module called Table if you wish to have more than one at a given time.

    module Model exposing ...

    type alias Model =
        { session: Maybe Session
        , currentDate: Date
        , table: Table
        }

    init : Date -> Model
    init date = 
            { session = Nothing
            , currentDate = date
            , table = Table.init
            }

Changes

  • Here is the top application model which every app must have.
  • Notice that this module is a single source of truth, meaning that there are no two models which can become out of sync because we removed all the dependencies. So if this will change, we are 100% certain that views will display correct data.

    module View exposing ...
   
   -- Helper record for reducing size of the view signature
    type alias TableView a msg = 
       { table: Table
       , data: List a
       , date: Date
       , onPagination: msg
       , onFilter: msg
       -- and other fields required to display a table
       }

    initialTable: msg -> msg -> TableView a msg
    initialTable pagMsg filterMsg = 
       { table = Table.initial 
       , data = []
       , date = -- some defaul date
       , onPagination: pagMsg
       , onFilter: filterMsg
      }

    -- Main view function
    view : Model -> Html Msg
    view {session, currentDate, table} = 
      case session of 
          Nothing -> text "no data to display"
          Just data -> 
               initialTable ClickNextPage TogglePagination
                   |> (\table -> { table | data = data, date = currentDate, table = table })
                   |> tableView

   tableView: TableView -> Html msg
   tableView {table, data, date, onPagination, onFilter} = 
       div [] 
          [ filterView table.filter date onFilter
          , paginationView table.pagination onPagination
          ]
   -- views for filter and pagination

Changes

  • Created global View module. You would probably split it into TableView.elm, PaginationView.elm etc…
  • Created helper record for passing around data required to display a view. Without it, our function signatures would be very long.

We also need to create Main, Messages and Update modules.

Hope this will help you.

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.