Bucketed Faceted Filters
Faceted values grouped into buckets for numeric and date columns. Ports examples/react/filters-faceted-bucketed from TanStack Table.
FiltersFacetedBucketed.elm
module FiltersFacetedBucketed exposing (main)
{-| Ports `examples/react/filters-faceted-bucketed/src/main.tsx`.
Continuous values (a login time, a byte count) are bucketed without a hidden
derived column: one set of bucket definitions feeds both
- `Table.withGetUniqueValues`, so `Table.facetedUniqueValues` counts
buckets instead of raw values, and
- a `FilterFn` whose `resolveDataValue` maps a cell to its bucket key,
which is exactly what `constructFilterFn` does in the React example. Tick
more than one bucket to match either; the counts react to the filters on the
other columns.
-}
import Browser
import FiltersFacetedBucketed.Accounts as Accounts exposing (Account)
import Html exposing (Html, button, div, fieldset, h1, input, label, p, span, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (checked, class, colspan, placeholder, type_, value)
import Html.Events exposing (onClick, onInput)
import Shared.Controls as Controls
import Table
import Table.FilterFn as FilterFn exposing (FilterFn)
import Table.Value as Value exposing (Value)
import Time
-- BUCKETS
type alias Bucket =
{ value : String
, label : String
, test : Value -> Bool
}
{-| Relative date buckets need a stable reference time.
-}
now : Int
now =
Time.posixToMillis Accounts.referenceDate
day : Int
day =
24 * 60 * 60 * 1000
startOfToday : Int
startOfToday =
now - modBy day now
startOfYesterday : Int
startOfYesterday =
startOfToday - day
startOfWeek : Int
startOfWeek =
startOfToday - (daysSinceMonday * day)
daysSinceMonday : Int
daysSinceMonday =
case Time.toWeekday Time.utc Accounts.referenceDate of
Time.Mon ->
0
Time.Tue ->
1
Time.Wed ->
2
Time.Thu ->
3
Time.Fri ->
4
Time.Sat ->
5
Time.Sun ->
6
startOfMonth : Int
startOfMonth =
startOfToday - ((Time.toDay Time.utc Accounts.referenceDate - 1) * day)
lastLoginBuckets : List Bucket
lastLoginBuckets =
[ Bucket "today" "Today" (atOrAfter startOfToday)
, Bucket "yesterday" "Yesterday" (atOrAfter startOfYesterday)
, Bucket "this-week" "This week" (atOrAfter startOfWeek)
, Bucket "this-month" "This month" (atOrAfter startOfMonth)
, Bucket "older" "Older" (always True)
]
atOrAfter : Int -> Value -> Bool
atOrAfter millis v =
case v of
Value.Date posix ->
Time.posixToMillis posix >= millis
_ ->
False
gigabyte : Float
gigabyte =
1024 * 1024 * 1024
storageBuckets : List Bucket
storageBuckets =
[ Bucket "under-1-gb" "< 1 GB" (below gigabyte)
, Bucket "1-to-10-gb" "1–10 GB" (below (10 * gigabyte))
, Bucket "10-to-100-gb" "10–100 GB" (below (100 * gigabyte))
, Bucket "100-gb-plus" "100+ GB" (always True)
]
below : Float -> Value -> Bool
below limit v =
Value.toNumber v < limit
{-| The first bucket whose test passes, as its key.
-}
bucketKey : List Bucket -> Value -> String
bucketKey buckets v =
List.filter (\bucket -> bucket.test v) buckets
|> List.head
|> Maybe.map .value
|> Maybe.withDefault ""
{-| Faceting and filtering deliberately share the same bucket definitions.
The column keeps its raw value for rendering and every other operation.
-}
bucketFilter : List Bucket -> FilterFn
bucketFilter buckets =
FilterFn.custom (\bucketValue selected -> List.member bucketValue (itemsOf selected))
|> FilterFn.withResolveDataValue (bucketKey buckets >> Value.String)
|> FilterFn.withAutoRemove (itemsOf >> List.isEmpty)
itemsOf : Value -> List Value
itemsOf v =
case v of
Value.List items ->
items
_ ->
[]
-- CONFIG
config : Table.Config Account
config =
Table.config
[ Table.column "name" (.name >> Value.String)
|> Table.withHeader "Account"
|> Table.withFilterFn FilterFn.includesString
, Table.column "lastLogin" (.lastLogin >> Value.Date)
|> Table.withHeader "Last login"
|> Table.withGetUniqueValues
(\row -> [ Value.String (bucketKey lastLoginBuckets (Value.Date row.lastLogin)) ])
|> Table.withFilterFn (bucketFilter lastLoginBuckets)
, Table.column "storageBytes" (.storageBytes >> Value.Number)
|> Table.withHeader "Storage"
|> Table.withGetUniqueValues
(\row -> [ Value.String (bucketKey storageBuckets (Value.Number row.storageBytes)) ])
|> Table.withFilterFn (bucketFilter storageBuckets)
, Table.column "files" (.files >> toFloat >> Value.Number)
|> Table.withHeader "Files"
|> Table.withEnableColumnFilter False
]
|> Table.withGetRowId (\a _ _ -> a.id)
bucketsFor : String -> Maybe (List Bucket)
bucketsFor columnId =
case columnId of
"lastLogin" ->
Just lastLoginBuckets
"storageBytes" ->
Just storageBuckets
_ ->
Nothing
-- MODEL
rowCount : Int
rowCount =
5000
type alias Model =
{ state : Table.State
, seed : Int
, data : List Account
}
init : Model
init =
{ state = Table.initialState
, seed = 42
, data = Accounts.makeData 42 rowCount
}
type alias Stages =
{ core : Table.RowModel Account
, prePaginated : Table.RowModel Account
, paginated : Table.RowModel Account
}
stages : Model -> Stages
stages model =
let
core : Table.RowModel Account
core =
Table.coreRowModelFromList config model.state model.data
prePaginated : Table.RowModel Account
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
| ClearFilters
| FilterChanged String Value
| BucketToggled String String
| 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 = Accounts.makeData seed rowCount }
ClearFilters ->
{ model | state = Table.resetColumnFilters model.state }
FilterChanged columnId v ->
{ model
| state =
Table.setColumnFilter config (stages model).core columnId v model.state
}
BucketToggled columnId bucket ->
let
selected : List Value
selected =
Table.getFilterValue model.state columnId
|> Maybe.map itemsOf
|> Maybe.withDefault []
next : List Value
next =
if List.member (Value.String bucket) selected then
List.filter (\v -> v /= Value.String bucket) selected
else
selected ++ [ Value.String bucket ]
in
{ model
| state =
Table.setColumnFilter config
(stages model).core
columnId
(Value.List next)
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" ]
[ h1 [] [ text "Bucketed faceted filters" ]
, p [ class "muted" ]
[ text "Bucket continuous values without a hidden derived column. Select more than one bucket to match either value; counts react to filters on the other columns." ]
, div [ class "button-row" ]
[ button [ onClick RegenerateData ] [ text "Regenerate Data" ]
, button [ onClick ClearFilters ] [ text "Clear all filters" ]
]
, div [ class "spacer-sm" ] []
, table []
[ thead []
[ tr []
(List.map (viewHeaderCell model current) (Table.flatHeaders config state))
]
, tbody [] (List.map (viewRow model) 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 -> Stages -> Table.Header Account -> Html Msg
viewHeaderCell model current 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
viewFilter model current columnId
else
text ""
]
viewFilter : Model -> Stages -> String -> Html Msg
viewFilter model current columnId =
let
filterValue : Value
filterValue =
Maybe.withDefault Value.Null (Table.getFilterValue model.state columnId)
in
case bucketsFor columnId of
Just buckets ->
let
counts : List ( Value, Int )
counts =
Table.facetedUniqueValues config model.state current.core columnId
in
fieldset [ class "facet-options" ]
(List.map (viewBucket columnId (itemsOf filterValue) counts) buckets)
Nothing ->
input
[ type_ "text"
, class "filter"
, placeholder "Search…"
, value (Value.toString filterValue)
, onInput (Value.String >> FilterChanged columnId)
]
[]
viewBucket : String -> List Value -> List ( Value, Int ) -> Bucket -> Html Msg
viewBucket columnId selected counts bucket =
let
count : Int
count =
counts
|> List.filter (\( v, _ ) -> v == Value.String bucket.value)
|> List.head
|> Maybe.map Tuple.second
|> Maybe.withDefault 0
in
label []
[ input
[ type_ "checkbox"
, checked (List.member (Value.String bucket.value) selected)
, onClick (BucketToggled columnId bucket.value)
]
[]
, span [] [ text bucket.label ]
, span [ class "count" ] [ text (Controls.formatInt count) ]
]
viewRow : Model -> Table.Row Account -> Html Msg
viewRow model row =
tr [] (List.map viewCell (Table.getAllCells config model.state row))
viewCell : Table.Cell -> Html Msg
viewCell cell =
td []
[ text
(case cell.columnId of
"storageBytes" ->
Accounts.formatBytes (Value.toNumber cell.value)
"files" ->
Controls.formatInt (round (Value.toNumber cell.value))
_ ->
Controls.valueToString cell.value
)
]
main : Program () Model Msg
main =
Browser.sandbox { init = init, update = update, view = view }
FiltersFacetedBucketed/Accounts.elm
module FiltersFacetedBucketed.Accounts exposing
( Account
, makeData, referenceDate
, formatBytes
)
{-| The Elm counterpart of `makeData.ts` in
`examples/react/filters-faceted-bucketed`.
That example does not use the `Person` shape the rest of the examples share:
it needs a continuous date and a continuous size so the bucket filters have
something to bucket.
`dataReferenceDate` is `new Date()` there. The site compiles without flags,
so the reference is a fixed instant here and the data is generated relative
to it, which keeps the "Today" and "This week" buckets meaningful.
@docs Account
@docs makeData, referenceDate
@docs formatBytes
-}
import Random exposing (Generator)
import Shared.People as People
import Time
{-| One row.
-}
type alias Account =
{ id : String
, name : String
, lastLogin : Time.Posix
, storageBytes : Float
, files : Int
}
{-| `dataReferenceDate`: 2025-06-15T12:00:00Z.
-}
referenceDate : Time.Posix
referenceDate =
Time.millisToPosix 1749988800000
{-| `makeData seed len`.
-}
makeData : Int -> Int -> List Account
makeData seed len =
Random.step (accounts len) (Random.initialSeed seed)
|> Tuple.first
accounts : Int -> Generator (List Account)
accounts len =
List.range 0 (len - 1)
|> List.map
(\index ->
Random.map (\one -> { one | id = String.fromInt index }) account
)
|> List.foldr (Random.map2 (::)) (Random.constant [])
account : Generator Account
account =
Random.map4
(\first last ageAndStorage files ->
let
( ageInDays, storageInGb ) =
ageAndStorage
in
{ id = ""
, name = first ++ " " ++ last
, lastLogin =
Time.millisToPosix
(Time.posixToMillis referenceDate
- round (ageInDays * 24 * 60 * 60 * 1000)
)
, storageBytes = storageInGb * gigabyte
, files = files
}
)
(Random.uniform "Tanner" People.firstNames)
(Random.uniform "Linsley" People.lastNames)
(Random.map2 Tuple.pair
-- `faker.number.float({ min: 0, max: 1 }) ** 2 * 400`
(Random.map (\x -> x * x * 400) (Random.float 0 1))
-- `0.05 * 10 ** faker.number.float({ min: 0, max: 4 })`
(Random.map (\e -> 0.05 * 10 ^ e) (Random.float 0 4))
)
(Random.int 0 50000)
gigabyte : Float
gigabyte =
1024 * 1024 * 1024
{-| `formatBytes` from the React example.
-}
formatBytes : Float -> String
formatBytes value =
if value < gigabyte then
String.fromInt (round (value / (1024 * 1024))) ++ " MB"
else
oneDecimal (value / gigabyte) ++ " GB"
oneDecimal : Float -> String
oneDecimal value =
let
tenths : Int
tenths =
round (value * 10)
in
String.fromInt (tenths // 10) ++ "." ++ String.fromInt (modBy 10 tenths)