Basic (Dynamic Columns)

Columns built at runtime from the data's own keys. Ports examples/react/basic-dynamic-columns from TanStack Table.

BasicDynamicColumns.elm
module BasicDynamicColumns exposing (main)

{-| Ports `examples/react/basic-dynamic-columns/src/main.tsx`.

The columns are built at runtime from a list of field names instead of a
hard-coded column list. For each field the example

1.  detects the value's data type,
2.  picks a sort fn and a filter fn that suit that type,
3.  renders a different filter control per type,

and the distinct values / min-max behind those controls come from
`Table.facetedUniqueValues` and `Table.facetedMinMax`, not from a hand-rolled
scan of the data. The checkboxes add and remove fields, which rebuilds the
whole column list.

-}

import Browser
import Html exposing (Html, button, div, input, label, option, p, select, span, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (checked, class, colspan, placeholder, selected, type_, value)
import Html.Events exposing (onCheck, onClick, onInput)
import Shared.Controls as Controls
import Shared.People as People exposing (Person)
import Table
import Table.FilterFn as FilterFn exposing (FilterFn)
import Table.SortFn as SortFn exposing (SortFn)
import Table.Value as Value exposing (Value)
import Time



-- THE DATA, AS FIELDS


{-| The runtime-detected data type of a field.
-}
type DataType
    = TypeString
    | TypeNumber
    | TypeBoolean
    | TypeDate


type alias Field =
    { key : String
    , dataType : DataType
    , read : Person -> Value
    }


{-| Elm records have no `Object.keys`, so the "shape of the data" is this
list. Everything below is derived from it at runtime.
-}
fields : List Field
fields =
    [ Field "firstName" TypeString (.firstName >> Value.String)
    , Field "lastName" TypeString (.lastName >> maybeString)
    , Field "status" TypeString (.status >> People.statusToString >> Value.String)
    , Field "age" TypeNumber (.age >> toFloat >> Value.Number)
    , Field "visits" TypeNumber (.visits >> maybeNumber)
    , Field "progress" TypeNumber (.progress >> toFloat >> Value.Number)

    -- `Shared.People.Person` has no boolean field; `rank` stands in for the
    -- React example's `isActive` so the boolean branch has something to show.
    , Field "isActive" TypeBoolean (\person -> Value.Bool (person.rank >= 50))
    , Field "createdAt" TypeDate (.createdAt >> Value.Date)
    ]


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


fieldFor : String -> Maybe Field
fieldFor key =
    List.head (List.filter (\field -> field.key == key) fields)



-- DERIVING COLUMNS


config : List String -> Table.Config Person
config keys =
    Table.config (List.filterMap columnFor keys)
        |> Table.withGetRowId (\person _ _ -> person.id)


columnFor : String -> Maybe (Table.Column Person)
columnFor key =
    fieldFor key
        |> Maybe.map
            (\field ->
                Table.column field.key field.read
                    |> Table.withHeader (formatHeader field.key)
                    |> Table.withSortFn (sortFnFor field.dataType)
                    |> Table.withFilterFn (filterFnFor field.dataType)
            )


{-| Turn a data key like "firstName" into a readable header like
"First Name".
-}
formatHeader : String -> String
formatHeader key =
    let
        withSpaces : String
        withSpaces =
            String.foldl
                (\char acc ->
                    if Char.isUpper char && acc /= "" then
                        acc ++ " " ++ String.fromChar char

                    else
                        acc ++ String.fromChar char
                )
                ""
                key
    in
    String.toUpper (String.left 1 withSpaces) ++ String.dropLeft 1 withSpaces


sortFnFor : DataType -> SortFn
sortFnFor dataType =
    case dataType of
        TypeNumber ->
            SortFn.basic

        TypeBoolean ->
            SortFn.basic

        TypeDate ->
            SortFn.datetime

        TypeString ->
            SortFn.alphanumeric


{-| Unlike the React example this needs no hand-written filter fns: the
package ships the boolean and date-range cases as `equalsString` and
`inDateRange`.
-}
filterFnFor : DataType -> FilterFn
filterFnFor dataType =
    case dataType of
        TypeNumber ->
            FilterFn.inNumberRange

        TypeBoolean ->
            FilterFn.equalsString

        TypeDate ->
            FilterFn.inDateRange

        TypeString ->
            FilterFn.includesString


renderValue : DataType -> Value -> String
renderValue dataType v =
    case ( dataType, v ) of
        ( _, Value.Null ) ->
            ""

        ( TypeDate, _ ) ->
            Controls.isoDate (dateOf v)

        ( TypeBoolean, Value.Bool True ) ->
            "✅"

        ( TypeBoolean, _ ) ->
            "❌"

        _ ->
            Value.toString v


dateOf : Value -> Time.Posix
dateOf v =
    case v of
        Value.Date posix ->
            posix

        _ ->
            Time.millisToPosix 0



-- MODEL


rowCount : Int
rowCount =
    1000


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


init : Model
init =
    { state = Table.initialState
    , seed = 42
    , data = People.makeData 42 [ rowCount ]
    , keys = List.map .key fields
    }


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


stages : Model -> Stages
stages model =
    let
        cfg : Table.Config Person
        cfg =
            config model.keys

        core : Table.RowModel Person
        core =
            Table.coreRowModelFromList cfg model.state model.data

        filtered : Table.RowModel Person
        filtered =
            Table.filteredRowModel cfg model.state core
    in
    { core = core
    , filtered = filtered
    , sorted = Table.sortedRowModel cfg model.state filtered
    }



-- UPDATE


type Msg
    = RegenerateData
    | ColumnToggled String Bool
    | SortClicked String Bool
    | FilterChanged String Value


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

        ColumnToggled key on ->
            { model
                | keys =
                    if on then
                        List.filter (\k -> List.member k model.keys || k == key)
                            (List.map .key fields)

                    else
                        List.filter (\k -> k /= key) model.keys
            }

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

        FilterChanged columnId v ->
            { model
                | state =
                    Table.setColumnFilter (config model.keys)
                        (stages model).core
                        columnId
                        v
                        model.state
            }



-- VIEW


view : Model -> Html Msg
view model =
    let
        cfg : Table.Config Person
        cfg =
            config model.keys

        current : Stages
        current =
            stages model
    in
    div [ class "demo-root" ]
        [ p [ class "muted" ]
            [ text "Columns, sort fns, filter fns, and filter controls are all derived from the data type of each field, not from a hard-coded column definition." ]
        , div [ class "button-row" ]
            [ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
        , div [ class "button-row" ] (List.map (fieldToggle model) fields)
        , div [ class "spacer-sm" ] []
        , table []
            [ thead []
                [ tr []
                    (List.map (viewHeaderCell model cfg current)
                        (Table.flatHeaders cfg model.state)
                    )
                ]
            , tbody []
                (List.map (viewRow model cfg) (List.take 15 current.sorted.rows))
            ]
        , div [ class "spacer-sm" ] []
        , div [] [ text (Controls.formatInt (List.length current.sorted.rows) ++ " Rows") ]
        ]


fieldToggle : Model -> Field -> Html Msg
fieldToggle model field =
    label [ class "inline-controls" ]
        [ input
            [ type_ "checkbox"
            , checked (List.member field.key model.keys)
            , onCheck (ColumnToggled field.key)
            ]
            []
        , text (formatHeader field.key)
        ]


viewHeaderCell : Model -> Table.Config Person -> Stages -> Table.Header Person -> Html Msg
viewHeaderCell model cfg current header =
    let
        columnId : String
        columnId =
            Table.headerColumnId header
    in
    th [ colspan (Table.headerColSpan header) ]
        [ span
            [ class "sortable", Controls.onClickShift (SortClicked columnId) ]
            [ text
                (formatHeader columnId
                    ++ Controls.sortArrow (Table.getIsSorted model.state columnId)
                )
            ]
        , viewFilter model cfg current columnId
        ]


viewFilter : Model -> Table.Config Person -> Stages -> String -> Html Msg
viewFilter model cfg current columnId =
    let
        filterValue : Value
        filterValue =
            Maybe.withDefault Value.Null (Table.getFilterValue model.state columnId)

        dataType : DataType
        dataType =
            fieldFor columnId
                |> Maybe.map .dataType
                |> Maybe.withDefault TypeString
    in
    case dataType of
        TypeNumber ->
            let
                minMax : Maybe ( Float, Float )
                minMax =
                    Table.facetedMinMax cfg model.state current.core columnId
            in
            div [ class "filter-row" ]
                [ rangeInput "number" columnId filterValue 0 (bound "Min" (Maybe.map Tuple.first minMax))
                , rangeInput "number" columnId filterValue 1 (bound "Max" (Maybe.map Tuple.second minMax))
                ]

        TypeDate ->
            div [ class "filter-row" ]
                [ rangeInput "date" columnId filterValue 0 ""
                , rangeInput "date" columnId filterValue 1 ""
                ]

        TypeBoolean ->
            select
                [ onInput (Value.String >> FilterChanged columnId)
                , Controls.onChange (Value.String >> FilterChanged columnId)
                ]
                (List.map (filterOption (Value.toString filterValue))
                    [ ( "", "All" ), ( "true", "Yes" ), ( "false", "No" ) ]
                )

        TypeString ->
            let
                uniqueValues : List String
                uniqueValues =
                    Table.facetedUniqueValues cfg model.state current.core columnId
                        |> List.map (Tuple.first >> Value.toString)
                        |> List.sort
            in
            if not (List.isEmpty uniqueValues) && List.length uniqueValues <= 10 then
                select
                    [ onInput (Value.String >> FilterChanged columnId)
                    , Controls.onChange (Value.String >> FilterChanged columnId)
                    ]
                    (List.map (filterOption (Value.toString filterValue))
                        (( "", "All" ) :: List.map (\v -> ( v, v )) uniqueValues)
                    )

            else
                input
                    [ type_ "text"
                    , class "filter"
                    , value (Value.toString filterValue)
                    , placeholder
                        ("Search... (" ++ String.fromInt (List.length uniqueValues) ++ ")")
                    , onInput (Value.String >> FilterChanged columnId)
                    ]
                    []


bound : String -> Maybe Float -> String
bound name limit =
    case limit of
        Nothing ->
            name

        Just n ->
            name ++ " (" ++ Controls.round2 n ++ ")"


filterOption : String -> ( String, String ) -> Html Msg
filterOption current ( optionValue, label_ ) =
    option [ value optionValue, selected (current == optionValue) ] [ text label_ ]


{-| One end of a two-part range filter value, `Value.List [ min, max ]`.
-}
rangeInput : String -> String -> Value -> Int -> String -> Html Msg
rangeInput inputType columnId filterValue index hint =
    input
        [ type_ inputType
        , class "filter"
        , placeholder hint
        , value (rangeEnd index filterValue)
        , onInput (\typed -> FilterChanged columnId (setRangeEnd index typed filterValue))
        ]
        []


rangeEnd : Int -> Value -> String
rangeEnd index v =
    case v of
        Value.List items ->
            List.drop index items
                |> List.head
                |> Maybe.map Value.toString
                |> Maybe.withDefault ""

        _ ->
            ""


setRangeEnd : Int -> String -> Value -> Value
setRangeEnd index typed v =
    let
        other : String
        other =
            rangeEnd (1 - index) v
    in
    if index == 0 then
        Value.List [ Value.String typed, Value.String other ]

    else
        Value.List [ Value.String other, Value.String typed ]


viewRow : Model -> Table.Config Person -> Table.Row Person -> Html Msg
viewRow model cfg row =
    tr []
        (List.map (viewCell >> td []) (Table.getAllCells cfg model.state row))


viewCell : Table.Cell -> List (Html Msg)
viewCell cell =
    [ text
        (renderValue
            (fieldFor cell.columnId |> Maybe.map .dataType |> Maybe.withDefault TypeString)
            cell.value
        )
    ]


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