Infinite Scrolling

Pages of rows fetched as you scroll, appended to the data and re-run through the pipeline. Ports examples/react/virtualized-infinite-scrolling from TanStack Table.

VirtualizedInfiniteScrolling.elm
module VirtualizedInfiniteScrolling exposing (main)

{-| Infinite Scrolling.

Ports `examples/react/virtualized-infinite-scrolling/src/main.tsx` from
TanStack Table: rows arrive a page at a time as the reader nears the bottom
of a virtualized list, and each page is appended to the data the pipeline
runs over.

The React example fetches from a fake API with TanStack Query's
`useInfiniteQuery`. There is no query library here and no server, so the
fetch is `Process.sleep 200` followed by a slice of a list that stands in for
the database. Everything else is the same shape: a page counter, a
`Fetching More...` indicator, a count of what has been loaded against the
total, and a scroll handler that asks for the next page once the reader is
within 500 pixels of the end.

Sorting is client-side here, so it re-runs the pipeline over every row
loaded so far rather than refetching. The React example sorts on the server
with `manualSorting`; see the Client-Side vs Server-Side guide for that
split. As in the React example, sorting scrolls back to the top, because the
row under the reader is no longer the row that was there.

-}

import Browser
import Browser.Dom
import Html exposing (Html, div, p, span, strong, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (on)
import InfiniteList
import Json.Decode as Decode
import Process
import Shared.Controls as Controls
import Shared.People as People exposing (Person)
import Table
import Table.Value as Value exposing (Value)
import Task



-- 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



-- THE STAND-IN BACKEND


{-| `makeData.ts` in the React example builds 1,000 people once and slices
them; so does this.
-}
database : List Person
database =
    People.makeData 42 [ totalRowCount ]


totalRowCount : Int
totalRowCount =
    1000


fetchSize : Int
fetchSize =
    50


fetchLatency : Float
fetchLatency =
    200


{-| One page of the fake API, after the fake latency.
-}
fetchPage : Int -> Cmd Msg
fetchPage pageIndex =
    Process.sleep fetchLatency
        |> Task.perform (\_ -> PageArrived (List.take fetchSize (List.drop (pageIndex * fetchSize) database)))



-- MODEL


rowHeight : Int
rowHeight =
    33


containerHeight : Int
containerHeight =
    600


{-| How close to the end the reader has to get before the next page is asked
for, in pixels. TanStack's `scrollHeight - scrollTop - clientHeight < 500`.
-}
fetchThreshold : Float
fetchThreshold =
    500


containerId : String
containerId =
    "infinite-scroll-container"


type alias Model =
    { state : Table.State
    , loaded : List Person
    , fetching : Bool
    , scrollTop : Float
    , core : Table.RowModel Person
    , rows : List (Table.Row Person)
    , list : InfiniteList.Model
    }


init : () -> ( Model, Cmd Msg )
init _ =
    ( recompute
        { state = Table.initialState
        , loaded = []
        , fetching = True
        , scrollTop = 0
        , core = Table.coreRowModelFromList config Table.initialState []
        , rows = []
        , list = InfiniteList.init
        }
    , fetchPage 0
    )


{-| The pipeline over everything loaded so far, stopping at
`sortedRowModel`. Stored in the model, because `view` runs on every scroll
event and every arriving page.
-}
recompute : Model -> Model
recompute model =
    let
        core : Table.RowModel Person
        core =
            Table.coreRowModelFromList config model.state model.loaded
    in
    { model
        | core = core
        , rows = (Table.sortedRowModel config model.state core).rows
    }



-- UPDATE


type Msg
    = SortClicked String Bool
    | Scrolled InfiniteList.Model Float
    | PageArrived (List Person)
    | ScrolledToTop


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
                    , list = InfiniteList.init
                    , scrollTop = 0
                }
            , Task.attempt (\_ -> ScrolledToTop) (Browser.Dom.setViewportOf containerId 0 0)
            )

        Scrolled list scrollTop ->
            fetchIfNearBottom { model | list = list, scrollTop = scrollTop }

        PageArrived people ->
            -- A page can land while the reader is already at the bottom, so
            -- check again as soon as one arrives, the way the React example
            -- re-runs its check after every fetch.
            fetchIfNearBottom
                (recompute { model | loaded = model.loaded ++ people, fetching = False })

        ScrolledToTop ->
            ( model, Cmd.none )


{-| The whole of TanStack's `fetchMoreOnBottomReached`. The scroll height is
the row count times the row height, because every row is the same height.
-}
fetchIfNearBottom : Model -> ( Model, Cmd Msg )
fetchIfNearBottom model =
    let
        loadedCount : Int
        loadedCount =
            List.length model.loaded

        remaining : Float
        remaining =
            toFloat (loadedCount * rowHeight) - model.scrollTop - toFloat containerHeight
    in
    if not model.fetching && loadedCount < totalRowCount && remaining < fetchThreshold then
        ( { model | fetching = True }, fetchPage (loadedCount // fetchSize) )

    else
        ( model, Cmd.none )



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "demo-root" ]
        [ p []
            [ text "("
            , strong [] [ text (Controls.formatInt (List.length model.loaded)) ]
            , text (" of " ++ Controls.formatInt totalRowCount ++ " rows fetched)")
            ]
        , div
            [ class "virtual-container"
            , id containerId
            , style "height" (px (toFloat containerHeight))
            , onScrollWithTop 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 []
            [ if model.fetching then
                text "Fetching More..."

              else
                text "Scroll to the bottom to fetch the next page."
            ]
        ]


{-| `InfiniteList.onScroll` reports only its own model, and the fetch check
needs the scroll position as a number. An element takes one `scroll`
listener, so this decodes the event once and hands over both.
-}
onScrollWithTop : (InfiniteList.Model -> Float -> msg) -> Html.Attribute msg
onScrollWithTop toMsg =
    on "scroll"
        (Decode.map2 (\event scrollTop -> toMsg (InfiniteList.updateScroll event InfiniteList.init) scrollTop)
            Decode.value
            (Decode.at [ "target", "scrollTop" ] Decode.float)
        )


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)


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
        }