Sorting

Click and shift-click headers for single and multi-column sorting. Ports examples/react/sorting from TanStack Table.

Sorting.elm
module Sorting exposing (main)

{-| Ports `examples/react/sorting/src/main.tsx`.

Click a header to step its sort cycle, shift-click to add it to the sort
instead of replacing it. The columns show off the per-column knobs:
`sortUndefined`, `sortDescFirst`, an explicit `sortFn`, `invertSorting`, and
a custom row comparison for the `status` enum.

-}

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



-- CONFIG


config : Table.Config Person
config =
    Table.config
        [ Table.display "rowNumber"
            |> Table.withHeader "#"
        , Table.column "firstName" (.firstName >> Value.String)

        -- this column will sort in ascending order by default since it is a
        -- string column
        , Table.column "lastName" (.lastName >> maybeString)
            |> Table.withHeader "Last Name"
            -- force null values to the end
            |> Table.withSortUndefined Table.sortNullsLast
            -- first sort order will be ascending (nullable values can mess up
            -- auto detection of sort order)
            |> Table.withSortDescFirst False
        , Table.column "email" (.email >> Value.String)
            |> Table.withHeader "Email"
            |> Table.withSortFn SortFn.alphanumeric

        -- this column will sort in descending order by default since it is a
        -- number column
        , Table.column "age" (.age >> toFloat >> Value.Number)
            |> Table.withHeader "Age"
        , Table.column "visits" (.visits >> maybeNumber)
            |> Table.withHeader "Visits"
            |> Table.withSortUndefined Table.sortNullsLast
        , Table.column "status" (.status >> People.statusToString >> Value.String)
            |> Table.withHeader "Status"
            -- use our custom sorting function for this enum column
            |> Table.withCustomSort sortStatus
        , Table.column "progress" (.progress >> toFloat >> Value.Number)
            |> Table.withHeader "Profile Progress"
        , Table.column "rank" (.rank >> toFloat >> Value.Number)
            |> Table.withHeader "Rank"
            -- invert the sorting order (golf score-like where smaller is better)
            |> Table.withInvertSorting True
        , Table.column "createdAt" (.createdAt >> Value.Date)
            |> Table.withHeader "Created At"
        ]
        |> Table.withGetRowId (\person _ _ -> person.id)


{-| `sortStatusFn` in the React example: single, complicated, relationship.
-}
sortStatus : Table.Row Person -> Table.Row Person -> Order
sortStatus rowA rowB =
    compare
        (statusOrder (Table.rowOriginal rowA).status)
        (statusOrder (Table.rowOriginal rowB).status)


statusOrder : Status -> Int
statusOrder status =
    case status of
        Single ->
            0

        Complicated ->
            1

        Relationship ->
            2


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


rowCount : Int
rowCount =
    1000


type alias Model =
    { state : Table.State
    , seed : Int
    , data : List Person
    }


init : Model
init =
    { state = Table.initialState
    , seed = 42
    , data = People.makeData 42 [ rowCount ]
    }


type alias Stages =
    { filtered : Table.RowModel Person
    , sorted : Table.RowModel Person
    }


stages : Model -> Stages
stages model =
    let
        filtered : Table.RowModel Person
        filtered =
            Table.coreRowModelFromList config model.state model.data
                |> Table.filteredRowModel config model.state
    in
    { filtered = filtered
    , sorted = Table.sortedRowModel config model.state filtered
    }



-- UPDATE


type Msg
    = RegenerateData
    | SortClicked String Bool


update : Msg -> Model -> Model
update msg model =
    case msg of
        RegenerateData ->
            let
                seed : Int
                seed =
                    model.seed + 1
            in
            { model | seed = seed, data = People.makeData seed [ rowCount ] }

        SortClicked columnId multi ->
            { model
                | state =
                    Table.toggleSort config
                        (stages model).filtered
                        columnId
                        { desc = Nothing, multi = multi }
                        model.state
            }



-- VIEW


view : Model -> Html Msg
view model =
    let
        current : Stages
        current =
            stages model

        visibleRows : List (Table.Row Person)
        visibleRows =
            List.take 10 current.sorted.rows
    in
    div [ class "demo-root" ]
        [ div [ class "button-row" ]
            [ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
        , div [ class "spacer-sm" ] []
        , table []
            [ thead []
                (List.map (viewHeaderRow model current) (Table.headerGroups config model.state))
            , tbody [] (List.indexedMap (viewRow model.state) visibleRows)
            ]
        , div [] [ text (Controls.formatInt (List.length current.sorted.rows) ++ " Rows") ]
        , Controls.stateDump model.state
        ]


viewHeaderRow : Model -> Stages -> Table.HeaderGroup Person -> Html Msg
viewHeaderRow model current group =
    tr [] (List.map (viewHeaderCell model current) group.headers)


viewHeaderCell : Model -> Stages -> Table.Header Person -> Html Msg
viewHeaderCell model current header =
    let
        columnId : String
        columnId =
            Table.headerColumnId header

        canSort : Bool
        canSort =
            Table.getCanSort config columnId

        label : String
        label =
            headerLabel columnId ++ Controls.sortArrow (Table.getIsSorted model.state columnId)
    in
    th [ colspan (Table.headerColSpan header) ]
        [ if canSort then
            span
                [ class "sortable"
                , title (nextSortTitle model current columnId)
                , Controls.onClickShift (SortClicked columnId)
                ]
                [ text label ]

          else
            text label
        ]


nextSortTitle : Model -> Stages -> String -> String
nextSortTitle model current columnId =
    case Table.getNextSortingOrder config current.filtered model.state columnId False of
        Nothing ->
            "Clear sort"

        Just dir ->
            if dir == Table.sortAsc then
                "Sort ascending"

            else
                "Sort descending"


headerLabel : String -> String
headerLabel columnId =
    Table.findColumn config columnId
        |> Maybe.andThen Table.columnHeader
        |> Maybe.withDefault columnId


viewRow : Table.State -> Int -> Table.Row Person -> Html Msg
viewRow state displayIndex row =
    tr [] (List.map (viewCell displayIndex) (Table.getAllCells config state row))


viewCell : Int -> Table.Cell -> Html Msg
viewCell displayIndex cell =
    td []
        [ if cell.columnId == "rowNumber" then
            text (String.fromInt (displayIndex + 1))

          else
            text (Controls.valueToString cell.value)
        ]


main : Program () Model Msg
main =
    Browser.sandbox { init = init, update = update, view = view }