How to structure data for tab views

Hello everyone,
I’m building a simple web application with an handful of tabs showing different parts of the Model. Those tabs are different but also share some data between them, so I didn’t create fully fledged “Model-View-Update” subcomponents like in other routed SPA examples (see GitHub - rtfeldman/elm-spa-example: A Single Page Application written in Elm). Specifically, the model contains a dictionary of integers and each tab displays a subset of those (plus other information contained in the Model); the subsets are not necessarily distinct, so it wouldn’t make sense to define a submodel for each one.

Besides a Main.elm file I created a Tabs folder with a module for each tab exposing just a view function. I’m puzzled about what should be the arguments for said function.

Initially I thought to use just the Model defined in Main.elm, but that creates a circular dependency as Main must depend on each tab to show it. I then settled for an extensible record that exposes only the fields I need for each tab and it feels a little cumbersome as I add fields to my Model. I could simply pass every needed field as a separate parameter (I only need to call the subview function once), but I guess the result wouldn’t change by much.

I feel each subview should just receive the entire Model as an explicit type, but that would require me to put the type in a separate module and that (separating model, view and update) is anti-pattern if I understand correctly.

What do you think would be a more elegant solution?

2 Likes

In my opinion it’s never a bad idea have data in it’s own module with functions only dealing with that data. So I don’t think it’s an antipattern if you can define clearly the data associated with a tab. But if that’s literally the whole model yeah I don’t know.

1 Like

This happens a lot on a project I’m working on - we’ve settled for an extensible record in each Tab module, it’s turned out quite nice as you’ve got all the information you need in that file.

1 Like

That’s exactly what I’m struggling with: I would love to separate the data in different modules, but there is no clear cut.

I’m happy to hear it’s not just me, and perhaps the extensible record is really the way to go.

Yea give it a try. The nice thing is, if you don’t like it it’s rarely dangerous to refactor to something else :grinning_face_with_smiling_eyes:

Although I am fairly satisfied with the extensible record approach I cooked up a mockup of the problem in case anyone is interested. I have to share it through google drive because Ellie does not allow multiple files: mockup.tar.gz - Google Drive

The extensible records approach seems fine because when using the tab views you don’t have to think about what data each tab needs, you just pass the model.

With regards to Messages.elm that’s where you’d want to be careful because it leads to the anti-pattern as well. The way you avoid creating Messages.elm is by abstracting the message creation in your views.

For e.g.

-- Main.elm

FirstTab.view model IncrementPar
-- Tabs.FirstTab.elm

view
  : { a | parameters : Dict String Int, language : Int, device : Element.DeviceClass }
  -> (String -> msg)
  -> Element.Element msg
view { parameters, language, device } onIncrement =
    let
        getPar key =
            Maybe.withDefault 0 <| Dict.get key parameters
    in
    Element.column [ Element.centerX, Element.spacing 10 ]
        [ Element.row []
            [ Element.text <| "Parameter 1: " ++ (String.fromInt <| getPar "par1")
            , Input.button [] { onPress = Just <| onIncrement "par1", label = Element.text "+" }
            ]
        , Element.row
            []
            [ Element.text <| "Parameter 2: " ++ (String.fromInt <| getPar "par2")
            , Input.button [] { onPress = Just <| onIncrement "par2", label = Element.text "+" }
            ]
        , showDeviceClass device
        , showLanguage language
        ]

Notice the use of msg instead of Msg. If you use msg instead of Msg for all your tabs then the Msg type can stay in Main.elm.

The way to think about it is to ask yourself:

  1. What data does my tab need? The answer to this is what you put in the extensible record.
  2. What messages can the tab generate? The answer to this is the “callbacks” you pass, onIncrement in the example above.

Another example:

-- Main.elm

SecondTab.view model ChangeLanguage IncrementPar
-- Tabs.SecondTab.elm

view 
  : { a | parameters : Dict String Int, language : Int, device : Element.DeviceClass }
  -> msg
  -> (String -> msg)
  -> Element.Element msg
view { parameters, language, device } onChangeLanguage onIncrement =
    let
        getPar key =
            Maybe.withDefault 0 <| Dict.get key parameters
    in
    Element.column [ Element.centerX, Element.spacing 10 ]
        [ Element.row
            []
            [ Element.text <| "Change Language: " ++ String.fromInt language
            , Input.button [] { onPress = Just onChangeLanguage, label = Element.text "change" }
            ]
        , Element.row
            []
            [ Element.text <| "Parameter 3: " ++ (String.fromInt <| getPar "par3")
            , Input.button [] { onPress = Just <| onIncrement "par3", label = Element.text "+" }
            ]
        , showDeviceClass device
        , showLanguage language
        ]

If you don’t want to think about the callbacks needed for each specific tab then you can use extensible records again. So for the two examples above you’d do the following:

-- Main.elm

type Msg
  = ChangeTab Int
  | ChangeText String
  | ChangePassword String
  | ChangeLanguage
  | IncrementPar String

callbacks =
  { onChangeLanguage = ChangeLanguage
  , onIncrement = IncrementPar
  }

FirstTab.view model callbacks
SecondTab.view model callbacks
-- Tabs.FirstTab.elm

view
  : { a | parameters : Dict String Int, language : Int, device : Element.DeviceClass }
  -> { a | onIncrement : String -> msg }
  -> Element.Element msg
view { parameters, language, device } { onIncrement } = ...
-- Tabs.SecondTab.elm

view
  : { a | parameters : Dict String Int, language : Int, device : Element.DeviceClass }
  -> { a | onChangeLanguage : msg, onIncrement : String -> msg }
  -> Element.Element msg
view { parameters, language, device } { onChangeLanguage, onIncrement } = ...
3 Likes

Although I didn’t know it for sure I imagined that sharing the Message type via a separate module was considered an anti-pattern; I still went for it because I consider the tabs to still be part of the main module, not completely independent view functions.
I guess the extensible record approach can work for this situation as well though!