Column Filters

Per-column text and range filters plus a global filter. Ports examples/react/filters from TanStack Table.

Filters.elm
module Filters exposing (main)

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

One filter control per column, chosen by a `filterVariant` the column
carries: free text, a numeric range, a `<select>`, or a date range. Each one
writes `Table.setColumnFilter`, which drops the filter again as soon as its
value goes blank (`shouldAutoRemoveFilter`).

-}

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



-- CONFIG


{-| The React example hangs this off `columnDef.meta`; here it is a plain
lookup beside the config.
-}
type FilterVariant
    = TextFilter
    | RangeFilter
    | SelectFilter
    | DateRangeFilter


variantOf : String -> FilterVariant
variantOf columnId =
    case columnId of
        "age" ->
            RangeFilter

        "visits" ->
            RangeFilter

        "progress" ->
            RangeFilter

        "status" ->
            SelectFilter

        "createdAt" ->
            DateRangeFilter

        _ ->
            TextFilter


config : Table.Config Person
config =
    Table.config
        [ Table.display "rowNumber"
            |> Table.withHeader "#"
        , Table.column "firstName" (.firstName >> Value.String)
        , Table.column "lastName" (.lastName >> maybeString)
            |> Table.withHeader "Last Name"
        , Table.column "fullName" fullName
            |> Table.withHeader "Full Name"
        , Table.column "age" (.age >> toFloat >> Value.Number)
            |> Table.withHeader "Age"
        , Table.column "visits" (.visits >> maybeNumber)
            |> Table.withHeader "Visits"
        , Table.column "status" (.status >> People.statusToString >> Value.String)
            |> Table.withHeader "Status"
            |> Table.withFilterFn FilterFn.equalsString
        , Table.column "progress" (.progress >> toFloat >> Value.Number)
            |> Table.withHeader "Profile Progress"
            |> Table.withFilterFn FilterFn.inNumberRange

        -- `Shared.People.Person` carries `createdAt` where the React example's
        -- `makeData` carries `birthDate`; the column is otherwise the same.
        , Table.column "createdAt" (.createdAt >> Value.Date)
            |> Table.withHeader "Created At"
            |> Table.withFilterFn FilterFn.inDateRange
        ]
        |> Table.withGetRowId (\person _ _ -> person.id)


fullName : Person -> Value
fullName person =
    Value.String (person.firstName ++ " " ++ Maybe.withDefault "" person.lastName)


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


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 =
    { core : Table.RowModel Person
    , prePaginated : Table.RowModel Person
    , paginated : Table.RowModel Person
    }


stages : Model -> Stages
stages model =
    let
        core : Table.RowModel Person
        core =
            Table.coreRowModelFromList config model.state model.data

        prePaginated : Table.RowModel Person
        prePaginated =
            core
                |> Table.filteredRowModel config model.state
                |> Table.groupedRowModel config model.state
                |> Table.sortedRowModel config model.state
                |> Table.expandedRowModel config model.state
    in
    { core = core
    , prePaginated = prePaginated
    , paginated = Table.paginatedRowModel config model.state prePaginated
    }



-- UPDATE


type Msg
    = RegenerateData
    | FilterChanged String Value
    | FirstPage
    | PreviousPage
    | NextPage
    | LastPage
    | GoToPage String
    | SetPageSize String


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

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

        FirstPage ->
            { model | state = Table.firstPage config model.state }

        PreviousPage ->
            { model | state = Table.previousPage config model.state }

        NextPage ->
            { model | state = Table.nextPage config model.state }

        LastPage ->
            { model | state = Table.lastPage config model.state (stages model).prePaginated }

        GoToPage text ->
            { model
                | state =
                    Table.setPage config
                        (Maybe.withDefault 1 (String.toInt text) - 1)
                        model.state
            }

        SetPageSize text ->
            { model
                | state = Table.setPageSize (Maybe.withDefault 10 (String.toInt text)) model.state
            }



-- VIEW


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

        state : Table.State
        state =
            model.state
    in
    div [ class "demo-root" ]
        [ div [ class "button-row" ]
            [ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
        , table []
            [ thead []
                [ tr []
                    (List.map (viewHeaderCell model) (Table.flatHeaders config state))
                ]
            , tbody [] (List.map (viewRow model current) current.paginated.rows)
            ]
        , div [ class "spacer-sm" ] []
        , Controls.pager
            { first = FirstPage
            , previous = PreviousPage
            , next = NextPage
            , last = LastPage
            , goToPage = GoToPage
            , setPageSize = SetPageSize
            , canPrevious = Table.getCanPreviousPage state
            , canNext = Table.getCanNextPage config state current.prePaginated
            , canLast = Table.getCanLastPage config state current.prePaginated
            , pageIndex = state.pagination.pageIndex
            , pageCount = Table.getPageCount config state current.prePaginated
            , pageSize = state.pagination.pageSize
            , pageSizes = [ 10, 20, 30, 40, 50 ]
            , showAll = False
            }
        , div [] [ text (Controls.formatInt (List.length current.prePaginated.rows) ++ " Rows") ]
        , Controls.stateDump state
        ]


viewHeaderCell : Model -> Table.Header Person -> Html Msg
viewHeaderCell model header =
    let
        columnId : String
        columnId =
            Table.headerColumnId header
    in
    th [ colspan (Table.headerColSpan header) ]
        [ div []
            [ text
                (Table.findColumn config columnId
                    |> Maybe.andThen Table.columnHeader
                    |> Maybe.withDefault columnId
                )
            ]
        , if Table.getCanFilter config columnId then
            div [] [ viewFilter model columnId ]

          else
            text ""
        ]


viewFilter : Model -> String -> Html Msg
viewFilter model columnId =
    let
        filterValue : Value
        filterValue =
            Maybe.withDefault Value.Null (Table.getFilterValue model.state columnId)
    in
    case variantOf columnId of
        DateRangeFilter ->
            div [ class "filter-row" ]
                [ rangeInput "date" columnId filterValue 0 ""
                , rangeInput "date" columnId filterValue 1 ""
                ]

        RangeFilter ->
            div [ class "filter-row" ]
                [ rangeInput "number" columnId filterValue 0 "Min"
                , rangeInput "number" columnId filterValue 1 "Max"
                ]

        SelectFilter ->
            select
                [ onInput (Value.String >> FilterChanged columnId)
                , Controls.onChange (Value.String >> FilterChanged columnId)
                ]
                (List.map (filterOption (Value.toString filterValue))
                    [ ( "", "All" )
                    , ( "complicated", "complicated" )
                    , ( "relationship", "relationship" )
                    , ( "single", "single" )
                    ]
                )

        TextFilter ->
            input
                [ type_ "text"
                , class "filter"
                , placeholder "Search..."
                , value (Value.toString filterValue)
                , onInput (Value.String >> FilterChanged columnId)
                ]
                []


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 -> Stages -> Table.Row Person -> Html Msg
viewRow model current row =
    let
        displayIndex : Int
        displayIndex =
            Table.displayIndex config model.state current.prePaginated row
    in
    tr []
        (List.map (viewCell displayIndex) (Table.getAllCells config model.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 }