Fuzzy Filtering
Fuzzy filtering keeps rows that approximately match what the user typed, rather than rows that contain it exactly. It is a filter function like any other, so it is wired up with the same builders as Column Filtering and Global Filtering. This page is the port of TanStack Table's Fuzzy Filtering guide.
TanStack's version of this page uses the @tanstack/match-sorter-utils
package for both the match test and the ranking. elm-table depends only on
elm/core and elm/time, and there is no Elm port of that package, so this
page shows how to write the matcher yourself. Everything below is ordinary Elm
in your own module.
State
Fuzzy filtering adds no state of its own. It is a filter function, so it reads whichever slice you attach it to.
-- as a column filter
-- State.columnFilters : List ColumnFilter
-- as the global filter
-- State.globalFilter : Value
Config options
None are specific to fuzzy filtering. The two that matter are the ones a filter function is attached with, both covered elsewhere:
| Option | Type | Default | Description |
|---|---|---|---|
globalFilterFn |
Maybe FilterFn |
Nothing |
Set your fuzzy FilterFn here with Table.withGlobalFilterFn to search every column fuzzily. See Global Filtering. |
manualFiltering |
Bool |
False |
True skips the filtering stage, so a client-side fuzzy filter never runs. |
Column options
| Builder | Type | Default | Description |
|---|---|---|---|
withCustomFilter |
(Row row -> Value -> Bool) -> Column row -> Column row |
Nothing |
A predicate on the whole row and the raw filter value. The simplest place for a fuzzy match. |
withFilterFn |
FilterFn -> Column row -> Column row |
Nothing |
A Table.FilterFn.FilterFn built with Table.FilterFn.custom, which compares cell Value against filter Value. |
withCustomSort |
(Row row -> Row row -> Order) -> Column row -> Column row |
Nothing |
Order the surviving rows by match quality. See Ranking. |
Writing a fuzzy matcher
A small, honest fuzzy test is subsequence matching: the characters the user
typed have to appear in the cell text in that order, but not next to each
other. Typing gho matches "Grace Hopper".
consume walks both strings once and returns how far into the text the match
completed, which is also the ranking score used later. Nothing means no
match.
fuzzySpan : String -> String -> Maybe Int
fuzzySpan needle haystack =
consume 0
(String.toList (String.toLower needle))
(String.toList (String.toLower haystack))
consume : Int -> List Char -> List Char -> Maybe Int
consume at needle haystack =
case needle of
[] ->
Just at
wanted :: restNeedle ->
case haystack of
[] ->
Nothing
next :: restHaystack ->
if wanted == next then
consume (at + 1) restNeedle restHaystack
else
consume (at + 1) needle restHaystack
fuzzyMatches : String -> String -> Bool
fuzzyMatches needle haystack =
fuzzySpan needle haystack /= Nothing
Using it as a column filter
Table.withCustomFilter hands your predicate the whole Row and the raw
filter Value, so it can build the text to search from any fields it likes.
The column below is TanStack's fullName column: an accessor that joins two
fields, filtered on the joined string.
fullName : Person -> String
fullName person =
person.firstName ++ " " ++ person.lastName
fullNameColumn : Table.Column Person
fullNameColumn =
Table.column "fullName" (fullName >> Value.String)
|> Table.withHeader "Full name"
|> Table.withCustomFilter
(\row filterValue ->
fuzzyMatches
(Value.toString filterValue)
(fullName (Table.rowOriginal row))
)
The filter value arrives unresolved, because there is no FilterFn to resolve
it with. Lowercase it yourself, as fuzzySpan does.
Using it as the global filter
For a search box that searches every column fuzzily, wrap the same matcher in
a Table.FilterFn.FilterFn and hand it to Table.withGlobalFilterFn.
Table.FilterFn.custom takes the cell value and the filter value;
withAutoRemove says when a filter value is blank enough to drop.
fuzzyFilterFn : FilterFn.FilterFn
fuzzyFilterFn =
FilterFn.custom
(\dataValue filterValue ->
fuzzyMatches (Value.toString filterValue) (Value.toString dataValue)
)
|> FilterFn.withAutoRemove (\filterValue -> Value.toString filterValue == "")
fuzzyGlobalConfig : Table.Config Person
fuzzyGlobalConfig =
Table.config [ fullNameColumn ]
|> Table.withGetRowId (\person _ _ -> person.id)
|> Table.withGlobalFilterFn fuzzyFilterFn
Transitions
The same ones as the filter slice you attached it to:
setColumnFilter and
resetColumnFilters for a column
filter, setGlobalFilter and
resetGlobalFilter for the global
one. There is nothing fuzzy-specific to call.
Queries
Also unchanged: getFilterValue,
getIsFiltered, and the rest of the
list on Column Filtering. A fuzzy filter is not
visible to the query functions as anything special.
Ranking by match quality
TanStack ranks fuzzy results by storing a rank on each row during filtering
(addMeta, read back as row.columnFiltersMeta[columnId]) and then sorting on
it. elm-table has no per-row columnFiltersMeta. A filter function
returns a Bool and writes nothing back onto the row, which is a deliberate
difference recorded in the port notes; see
Migrating from TanStack.
So ranking is done by scoring again in the comparator. Compute the score from
the row, exactly as the filter did, and compare the two scores. When they tie,
fall back to an ordinary comparison, which is what TanStack's fuzzySort does
too.
rankOf : String -> Person -> Int
rankOf query person =
fuzzySpan query (fullName person)
|> Maybe.withDefault (String.length (fullName person) + 1)
rankedColumn : String -> Table.Column Person
rankedColumn query =
fullNameColumn
|> Table.withCustomSort
(\rowA rowB ->
let
personA : Person
personA =
Table.rowOriginal rowA
personB : Person
personB =
Table.rowOriginal rowB
in
case Basics.compare (rankOf query personA) (rankOf query personB) of
EQ ->
SortFn.compare SortFn.alphanumeric
(Value.String (fullName personA))
(Value.String (fullName personB))
order ->
order
)
The comparator needs the query string, and a Column is built before the
State exists, so the column is a function of the query and the Config is
built per query rather than once at the top level.
configFor : String -> Table.Config Person
configFor query =
Table.config [ rankedColumn query ]
|> Table.withGetRowId (\person _ _ -> person.id)
rankedRows : String -> Table.State -> Table.RowModel Person
rankedRows query state =
let
cfg : Table.Config Person
cfg =
configFor query
in
Table.coreRowModelFromList cfg state people
|> Table.filteredRowModel cfg state
|> Table.sortedRowModel cfg state
Scoring twice, once to filter and once per comparison, costs more than
TanStack's stored rank. For a large table, score every row into a Dict
keyed by row id in your update and have the comparator read that instead.
What this page does not cover
@tanstack/match-sorter-utils,rankItem, andcompareItems. There is no Elm port. The subsequence matcher above stands in forrankItem; write a closer scorer if you need one.addMeta,columnFiltersMeta, and thefilterMetaslot. No filter function can write per-row metadata. Rank in the comparator instead, as above.- The
filterFnsandsortFnsregistries. Columns take functions directly, so there is nothing to register a"fuzzy"name in.
Example
Fuzzy Search, ported from TanStack's Fuzzy Search example.