Fuzzy Search Filters

A custom fuzzy filter function with match ranking. Ports examples/react/filters-fuzzy from TanStack Table.

FiltersFuzzy.elm
module FiltersFuzzy exposing (main)

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

`Shared.Fuzzy` replaces `@tanstack/match-sorter-utils`: `rankItem` scores a
value against the search text and `compareRankings` orders two scores.

The `fullName` column filters with that ranking through
`Table.withCustomFilter` and sorts with it through `Table.withCustomSort`,
falling back to `SortFn.alphanumeric` when two ranks tie. The search box at
the top is the global filter, which runs the same ranking over every column.

There is no `columnFiltersMeta` in this port, so the sort cannot read a rank
that filtering left behind: the config is a function of the current search
text instead, and the comparison re-ranks. Like the React example's effect,
typing in the `fullName` filter switches the sort to `fullName`.

-}

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



-- CONFIG


{-| The column list depends on the current `fullName` search, because the
fuzzy sort has to rank against it.
-}
config : String -> Table.Config Person
config search =
    Table.config
        [ Table.column "id" (.id >> Value.String)
            -- normal non-fuzzy filter column: exact match required
            |> Table.withFilterFn FilterFn.equalsString
        , Table.column "firstName" (.firstName >> Value.String)
            -- normal non-fuzzy filter column: case sensitive
            |> Table.withFilterFn FilterFn.includesStringSensitive
        , Table.column "lastName" (.lastName >> maybeString)
            |> Table.withHeader "Last Name"
            -- normal non-fuzzy filter column: case insensitive
            |> Table.withFilterFn FilterFn.includesString
        , Table.column "fullName" (fullName >> Value.String)
            |> Table.withHeader "Full Name"
            |> Table.withCustomFilter fuzzyFilter
            |> Table.withCustomSort (fuzzySort search)
        ]
        |> Table.withGlobalFilterFn fuzzyGlobalFilter
        |> Table.withGetRowId (\person _ _ -> person.id)


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


{-| `fuzzyFilter`: keep the row when the ranking passes.
-}
fuzzyFilter : Table.Row Person -> Value -> Bool
fuzzyFilter row filterValue =
    (Fuzzy.rankItem (fullName (Table.rowOriginal row)) (Value.toString filterValue)).passed


{-| The same ranking as a `FilterFn`, which is what a global filter takes.
-}
fuzzyGlobalFilter : FilterFn
fuzzyGlobalFilter =
    FilterFn.custom
        (\dataValue filterValue ->
            (Fuzzy.rankItem (Value.toString dataValue) (Value.toString filterValue)).passed
        )


{-| `fuzzySort`: rank first, alphanumeric as the tie-break.
-}
fuzzySort : String -> Table.Row Person -> Table.Row Person -> Order
fuzzySort search rowA rowB =
    let
        nameA : String
        nameA =
            fullName (Table.rowOriginal rowA)

        nameB : String
        nameB =
            fullName (Table.rowOriginal rowB)
    in
    case Fuzzy.compareRankings (Fuzzy.rankItem nameA search) (Fuzzy.rankItem nameB search) of
        EQ ->
            SortFn.compare SortFn.alphanumeric (Value.String nameA) (Value.String nameB)

        order ->
            order


maybeString : Maybe String -> Value
maybeString =
    Maybe.map Value.String >> 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 ]
    }


{-| The `fullName` filter text the fuzzy sort ranks against.
-}
searchOf : Model -> String
searchOf model =
    Table.getFilterValue model.state "fullName"
        |> Maybe.map Value.toString
        |> Maybe.withDefault ""


configOf : Model -> Table.Config Person
configOf model =
    config (searchOf model)


type alias Stages =
    { core : Table.RowModel Person
    , prePaginated : Table.RowModel Person
    , paginated : Table.RowModel Person
    }


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

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

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



-- UPDATE


type Msg
    = RegenerateData
    | GlobalFilterChanged String
    | FilterChanged String String
    | SortClicked String Bool
    | 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 ] }

        GlobalFilterChanged typed ->
            { model | state = Table.setGlobalFilter (Value.String typed) model.state }

        FilterChanged columnId typed ->
            let
                filtered : Table.State
                filtered =
                    Table.setColumnFilter (configOf model)
                        (stages model).core
                        columnId
                        (Value.String typed)
                        model.state
            in
            -- The React example's effect: filtering by fullName sorts by it.
            { model
                | state =
                    if columnId == "fullName" && typed /= "" then
                        Table.setSorting [ { id = "fullName", desc = False } ] filtered

                    else
                        filtered
            }

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

        FirstPage ->
            { model | state = Table.firstPage (configOf model) model.state }

        PreviousPage ->
            { model | state = Table.previousPage (configOf model) model.state }

        NextPage ->
            { model | state = Table.nextPage (configOf model) model.state }

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

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

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



-- VIEW


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

        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" ] ]
        , div []
            [ input
                [ type_ "text"
                , class "filter-wide"
                , placeholder "Search all columns..."
                , value (Value.toString state.globalFilter)
                , onInput GlobalFilterChanged
                ]
                []
            ]
        , div [ class "spacer-sm" ] []
        , table []
            [ thead []
                [ tr [] (List.map (viewHeaderCell model cfg) (Table.flatHeaders cfg state)) ]
            , tbody [] (List.map (viewRow cfg state) 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 cfg state current.prePaginated
            , canLast = Table.getCanLastPage cfg state current.prePaginated
            , pageIndex = state.pagination.pageIndex
            , pageCount = Table.getPageCount cfg 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.Config Person -> Table.Header Person -> Html Msg
viewHeaderCell model cfg header =
    let
        columnId : String
        columnId =
            Table.headerColumnId header
    in
    th [ colspan (Table.headerColSpan header) ]
        [ span
            [ class "sortable", Controls.onClickShift (SortClicked columnId) ]
            [ text
                ((Table.findColumn cfg columnId
                    |> Maybe.andThen Table.columnHeader
                    |> Maybe.withDefault columnId
                 )
                    ++ Controls.sortArrow (Table.getIsSorted model.state columnId)
                )
            ]
        , div []
            [ input
                [ type_ "text"
                , class "filter"
                , placeholder "Search..."
                , value
                    (Table.getFilterValue model.state columnId
                        |> Maybe.map Value.toString
                        |> Maybe.withDefault ""
                    )
                , onInput (FilterChanged columnId)
                ]
                []
            ]
        ]


viewRow : Table.Config Person -> Table.State -> Table.Row Person -> Html Msg
viewRow cfg state row =
    tr []
        (List.map (\cell -> td [] [ text (Controls.valueToString cell.value) ])
            (Table.getAllCells cfg state row)
        )


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