Virtualized Rows

50,000 sorted and filtered rows rendered through an Elm virtual list. Ports examples/react/virtualized-rows from TanStack Table.

VirtualizedRows.elm
module VirtualizedRows exposing (main)

{-| Virtualized Rows.

Ports `examples/react/virtualized-rows/src/main.tsx` from TanStack Table:
50,000 rows in a fixed-height scroll container, with only the rows in view
mounted. TanStack pairs the table with TanStack Virtual; the Elm counterpart
here is `FabienHenon/elm-infinite-list-view`, which owns the scroll position
and the window, while this package owns the rows.

Two things make it work:

  - The pipeline stops at `sortedRowModel`, so the virtual list sees all
    50,000 rows rather than one page of ten. Pagination and virtualization
    both cut the rendered row count, and running them together would leave
    the scroll bar describing a page instead of the table.
  - The row model is computed in `update` and stored in the model. `view`
    runs on every scroll event, and re-sorting 50,000 rows on every frame
    would make scrolling crawl.

The React example also has a row-selection column with shift-click ranges.
Selection is not what this example is about; see the Row Selection example
for it.

-}

import Browser
import Html exposing (Html, button, div, p, span, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (class, style)
import Html.Events exposing (onClick)
import InfiniteList
import Shared.Controls as Controls
import Shared.People as People exposing (Person)
import Table
import Table.Value as Value exposing (Value)



-- CONFIG


config : Table.Config Person
config =
    Table.config
        [ Table.column "id" (.id >> Value.String)
            |> Table.withHeader "ID"
            |> Table.withSize 70
        , Table.column "firstName" (.firstName >> Value.String)
            |> Table.withHeader "First Name"
            |> Table.withSize 150
        , Table.column "lastName" (.lastName >> maybeString)
            |> Table.withHeader "Last Name"
            |> Table.withSize 150
        , Table.column "age" (.age >> toFloat >> Value.Number)
            |> Table.withHeader "Age"
            |> Table.withSize 60
        , Table.column "visits" (.visits >> maybeNumber)
            |> Table.withHeader "Visits"
            |> Table.withSize 70
        , Table.column "status" (.status >> People.statusToString >> Value.String)
            |> Table.withHeader "Status"
            |> Table.withSize 130
        , Table.column "progress" (.progress >> toFloat >> Value.Number)
            |> Table.withHeader "Profile Progress"
            |> Table.withSize 140
        , Table.column "createdAt" (.createdAt >> Value.Date)
            |> Table.withHeader "Created At"
            |> Table.withSize 140
        ]
        |> Table.withGetRowId (\person _ _ -> person.id)


maybeString : Maybe String -> Value
maybeString =
    Maybe.map Value.String >> Maybe.withDefault Value.Null


maybeNumber : Maybe Int -> Value
maybeNumber =
    Maybe.map (toFloat >> Value.Number) >> Maybe.withDefault Value.Null



-- MODEL


{-| The React example generates 200,000 rows and offers a one-million-row
stress test. 50,000 is the number this port settles on: enough that the
window is obviously a window, and still a second or so to build.
-}
rowCount : Int
rowCount =
    50000


{-| Both halves of the virtual list have to agree on this: the CSS gives
every row exactly this height, and the list turns a scroll position into a
row index by dividing by it.
-}
rowHeight : Int
rowHeight =
    33


containerHeight : Int
containerHeight =
    600


type alias Model =
    { state : Table.State
    , seed : Int
    , data : List Person
    , core : Table.RowModel Person
    , rows : List (Table.Row Person)
    , list : InfiniteList.Model
    }


init : () -> ( Model, Cmd Msg )
init _ =
    ( recompute
        { state = Table.initialState
        , seed = 42
        , data = People.makeData 42 [ rowCount ]
        , core = Table.coreRowModelFromList config Table.initialState []
        , rows = []
        , list = InfiniteList.init
        }
    , Cmd.none
    )


{-| The whole pipeline, run once per `update` rather than once per frame. It
stops at `sortedRowModel`: no `paginatedRowModel`, so `.rows` is every row
there is, which is what the virtual list has to index into.
-}
recompute : Model -> Model
recompute model =
    let
        core : Table.RowModel Person
        core =
            Table.coreRowModelFromList config model.state model.data
    in
    { model
        | core = core
        , rows = (Table.sortedRowModel config model.state core).rows
    }



-- UPDATE


type Msg
    = SortClicked String Bool
    | RegenerateData
    | Scrolled InfiniteList.Model


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SortClicked columnId multi ->
            ( recompute
                { model
                    | state =
                        Table.toggleSort config
                            model.core
                            columnId
                            { desc = Nothing, multi = multi }
                            model.state
                }
            , Cmd.none
            )

        RegenerateData ->
            let
                seed : Int
                seed =
                    model.seed + 1
            in
            ( recompute { model | seed = seed, data = People.makeData seed [ rowCount ] }
            , Cmd.none
            )

        Scrolled list ->
            ( { model | list = list }, Cmd.none )



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "demo-root" ]
        [ div [ class "button-row" ]
            [ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
        , p []
            [ text (Controls.formatInt (List.length model.rows) ++ " rows, ")
            , text (String.fromInt (List.length (Table.visibleLeafColumns config model.state)) ++ " columns. ")
            , text "Click a header to sort, shift-click to add a column to the sort."
            ]
        , div
            [ class "virtual-container"
            , style "height" (px (toFloat containerHeight))
            , InfiniteList.onScroll Scrolled
            ]
            [ table
                [ class "virtual-head", style "width" (px (Table.totalSize config model.state)) ]
                [ thead [] [ tr [] (List.map (viewHeaderCell model) (Table.visibleLeafColumns config model.state)) ] ]
            , InfiniteList.view (listConfig model) model.list model.rows
            ]
        , p [ class "muted" ]
            [ text "Only the rows inside the viewport are in the DOM. Everything above and below them is one empty box the height of the rows it stands in for." ]
        ]


{-| `itemView` is handed the item itself, so a rendered row needs no lookup
back into the 50,000.
-}
listConfig : Model -> InfiniteList.Config (Table.Row Person) Msg
listConfig model =
    InfiniteList.config
        { itemView = \_ _ row -> viewRow model row
        , itemHeight = InfiniteList.withConstantHeight rowHeight
        , containerHeight = containerHeight
        }
        |> InfiniteList.withCustomContainer (bodyTable model)


{-| The list renders a `div` around its items by default. A table wants a
`table` and a `tbody`, and this puts them back.
-}
bodyTable : Model -> List ( String, String ) -> List (Html Msg) -> Html Msg
bodyTable model styles rows =
    table
        (class "virtual-body"
            :: style "width" (px (Table.totalSize config model.state))
            :: List.map (\( name, value ) -> style name value) styles
        )
        [ tbody [] rows ]


viewHeaderCell : Model -> Table.Column Person -> Html Msg
viewHeaderCell model column =
    let
        columnId : String
        columnId =
            Table.columnId column

        label : String
        label =
            Maybe.withDefault columnId (Table.columnHeader column)
                ++ Controls.sortArrow (Table.getIsSorted model.state columnId)
    in
    th
        [ class "sortable"
        , style "width" (px (Table.getColumnSize config model.state column))
        , Controls.onClickShift (SortClicked columnId)
        ]
        [ span [] [ text label ] ]


viewRow : Model -> Table.Row Person -> Html Msg
viewRow model row =
    tr [] (List.map (viewCell model row) (Table.visibleLeafColumns config model.state))


viewCell : Model -> Table.Row Person -> Table.Column Person -> Html Msg
viewCell model row column =
    td [ style "width" (px (Table.getColumnSize config model.state column)) ]
        [ text (Controls.valueToString (Table.getValue config row (Table.columnId column))) ]


px : Float -> String
px value =
    String.fromFloat value ++ "px"


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }