Aggregation
Aggregated values on group rows and in footers. Ports examples/react/aggregation from TanStack Table.
Aggregation.elm
module Aggregation exposing (main)
{-| Ports `examples/react/aggregation/src/main.tsx`.
Aggregation without grouping: the footers fold a column over a chosen set of
rows. The select picks which rows those are — the filtered ones (the
default), every core row, just this page, the filtered selection, or a
hand-picked three — and `Table.aggregationValueOf` takes that list.
`Amount` uses a scalar `sum`. `Score` is where the port differs: TanStack
lets one column carry several aggregation functions and returns a keyed
object, while `Table.withAggregationFn` takes one function, so the footer
folds `count`, `mean` and `extent` over the same rows itself with
`Table.AggregationFn`.
-}
import Aggregation.Sales as Sales exposing (Sale)
import Browser
import Html exposing (Html, button, div, h1, input, label, option, p, select, table, tbody, td, text, tfoot, th, thead, tr)
import Html.Attributes exposing (checked, class, colspan, selected, type_, value)
import Html.Events exposing (onClick, onInput)
import Shared.Controls as Controls
import Table
import Table.AggregationFn as AggregationFn exposing (AggregationFn)
import Table.FilterFn as FilterFn
import Table.Value as Value exposing (Value)
-- CONFIG
config : Table.Config Sale
config =
Table.config
[ Table.display "select"
, Table.column "category" (.category >> Value.String)
|> Table.withHeader "Category"
|> Table.withFilterFn FilterFn.includesString
, Table.column "item" (.item >> Value.String)
|> Table.withHeader "Item"
, Table.column "amount" (.amount >> Value.Number)
|> Table.withHeader "Amount"
|> Table.withAggregationFn AggregationFn.sum
, Table.column "score" (.score >> Value.Number)
|> Table.withHeader "Score"
|> Table.withAggregationFn AggregationFn.mean
]
|> Table.withGetRowId (\row _ _ -> row.id)
-- MODEL
rowCount : Int
rowCount =
2000
{-| Which rows the footers aggregate.
-}
type RowSource
= FilteredRows
| AllRows
| PageRows
| SelectedRows
| CustomRows
type alias Model =
{ state : Table.State
, seed : Int
, data : List Sale
, rowSource : RowSource
}
init : Model
init =
let
state : Table.State
state =
Table.initialState
in
{ state = { state | pagination = { pageIndex = 0, pageSize = 10 } }
, seed = 42
, data = Sales.makeData 42 rowCount
, rowSource = FilteredRows
}
type alias Stages =
{ core : Table.RowModel Sale
, filtered : Table.RowModel Sale
, paginated : Table.RowModel Sale
}
stages : Model -> Stages
stages model =
let
core : Table.RowModel Sale
core =
Table.coreRowModelFromList config model.state model.data
filtered : Table.RowModel Sale
filtered =
Table.filteredRowModel config model.state core
in
{ core = core
, filtered = filtered
, paginated = Table.paginatedRowModel config model.state filtered
}
{-| `getAggregationRows`.
-}
aggregationRows : Model -> Stages -> List (Table.Row Sale)
aggregationRows model current =
case model.rowSource of
FilteredRows ->
current.filtered.rows
AllRows ->
current.core.rows
PageRows ->
current.paginated.rows
SelectedRows ->
(Table.selectedRowModel model.state current.filtered).rows
CustomRows ->
List.take 3 current.core.rows
rowSourceLabel : RowSource -> String
rowSourceLabel source =
case source of
FilteredRows ->
"Filtered rows"
AllRows ->
"All rows"
PageRows ->
"Visible page"
SelectedRows ->
"Filtered selected rows"
CustomRows ->
"First three core rows"
rowSourceKey : RowSource -> String
rowSourceKey source =
case source of
FilteredRows ->
"filtered"
AllRows ->
"all"
PageRows ->
"page"
SelectedRows ->
"selected"
CustomRows ->
"custom"
rowSourceFromKey : String -> RowSource
rowSourceFromKey key =
case key of
"all" ->
AllRows
"page" ->
PageRows
"selected" ->
SelectedRows
"custom" ->
CustomRows
_ ->
FilteredRows
-- UPDATE
type Msg
= RegenerateData
| RowSourcePicked String
| CategoryFilterChanged String
| RowSelectionToggled (Table.Row Sale)
| AllPageRowsSelectionToggled
| 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 = Sales.makeData seed rowCount }
RowSourcePicked key ->
{ model | rowSource = rowSourceFromKey key }
CategoryFilterChanged typed ->
{ model
| state =
Table.setColumnFilter config
(stages model).core
"category"
(Value.String typed)
model.state
}
RowSelectionToggled row ->
{ model
| state =
Table.toggleRowSelected config (stages model).core row Nothing model.state
}
AllPageRowsSelectionToggled ->
{ model
| state =
Table.toggleAllPageRowsSelected config
(stages model).paginated
Nothing
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).filtered }
GoToPage typed ->
{ model
| state =
Table.setPage config
(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
current : Stages
current =
stages model
state : Table.State
state =
model.state
in
div [ class "demo-root" ]
[ h1 [] [ text "Aggregation without grouping" ]
, p [ class "muted" ]
[ text "Amount uses a scalar sum. Score runs count, mean, and range over the same rows." ]
, div [ class "button-row" ]
[ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
, div [ class "spacer-sm" ] []
, div [ class "controls" ]
[ label []
[ text "Category filter: "
, input
[ class "filter"
, value
(Table.getFilterValue state "category"
|> Maybe.map Value.toString
|> Maybe.withDefault ""
)
, onInput CategoryFilterChanged
]
[]
]
, label []
[ text "Total rows: "
, select [ onInput RowSourcePicked, Controls.onChange RowSourcePicked ]
(List.map (rowSourceOption model)
[ FilteredRows, AllRows, PageRows, SelectedRows, CustomRows ]
)
]
]
, div [ class "spacer-sm" ] []
, table []
[ thead []
[ tr []
(List.map (viewHeaderCell model current) (Table.flatHeaders config state))
]
, tbody [] (List.map (viewRow model) current.paginated.rows)
, tfoot []
(List.map (viewFooterRow model current) (Table.footerGroups config state))
]
, 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.filtered
, canLast = Table.getCanLastPage config state current.filtered
, pageIndex = state.pagination.pageIndex
, pageCount = Table.getPageCount config state current.filtered
, pageSize = state.pagination.pageSize
, pageSizes = [ 10, 20, 30, 40, 50 ]
, showAll = False
}
, div []
[ text
("Showing "
++ Controls.formatInt (List.length current.paginated.rows)
++ " of "
++ Controls.formatInt (Table.getRowCount config current.filtered)
++ " Rows"
)
]
, Controls.stateDump state
]
rowSourceOption : Model -> RowSource -> Html Msg
rowSourceOption model source =
option
[ value (rowSourceKey source), selected (model.rowSource == source) ]
[ text (rowSourceLabel source) ]
viewHeaderCell : Model -> Stages -> Table.Header Sale -> Html Msg
viewHeaderCell model current header =
let
columnId : String
columnId =
Table.headerColumnId header
in
th [ colspan (Table.headerColSpan header) ]
[ if columnId == "select" then
input
[ type_ "checkbox"
, checked (Table.getIsAllPageRowsSelected config model.state current.paginated)
, onClick AllPageRowsSelectionToggled
]
[]
else
text
(Table.findColumn config columnId
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault columnId
)
]
viewFooterRow : Model -> Stages -> Table.HeaderGroup Sale -> Html Msg
viewFooterRow model current group =
tr [] (List.map (viewFooterCell model current) group.headers)
viewFooterCell : Model -> Stages -> Table.Header Sale -> Html Msg
viewFooterCell model current header =
let
rows : List (Table.Row Sale)
rows =
aggregationRows model current
in
th [ colspan (Table.headerColSpan header) ]
[ text
(case Table.headerColumnId header of
"item" ->
rowSourceKey model.rowSource ++ " total"
"amount" ->
formatValue
(Table.aggregationValueOf config
current.core
"amount"
{ maxDepth = 0, rows = rows }
)
"score" ->
scoreSummary current rows
_ ->
""
)
]
{-| The keyed object TanStack's multi-aggregation column returns, folded
here one function at a time.
-}
scoreSummary : Stages -> List (Table.Row Sale) -> String
scoreSummary current rows =
let
values : List Value
values =
List.map (\row -> Table.getValue config row "score") rows
fold : AggregationFn -> String
fold fn =
formatValue (AggregationFn.aggregate fn values)
in
"count: "
++ fold AggregationFn.count
++ ", mean: "
++ fold AggregationFn.mean
++ ", range: "
++ fold AggregationFn.extent
{-| `formatValue`: an array prints as `a – b`, a number with at most two
decimals, and nothing at all as an em dash.
-}
formatValue : Value -> String
formatValue value =
case value of
Value.List items ->
String.join " – " (List.map formatValue items)
Value.Number n ->
Controls.formatFloat n
Value.Null ->
"—"
_ ->
Value.toString value
viewRow : Model -> Table.Row Sale -> Html Msg
viewRow model row =
tr [] (List.map (viewCell model row) (Table.getAllCells config model.state row))
viewCell : Model -> Table.Row Sale -> Table.Cell -> Html Msg
viewCell model row cell =
td []
[ case cell.columnId of
"select" ->
input
[ type_ "checkbox"
, checked (Table.getIsRowSelected model.state row)
, onClick (RowSelectionToggled row)
]
[]
"amount" ->
text (Controls.formatInt (round (Value.toNumber cell.value)))
_ ->
text (Controls.valueToString cell.value)
]
main : Program () Model Msg
main =
Browser.sandbox { init = init, update = update, view = view }
Aggregation/Sales.elm
module Aggregation.Sales exposing (Sale, makeData)
{-| The Elm counterpart of `makeData.ts` in
`examples/react/aggregation`: sales rows, not people.
@docs Sale, makeData
-}
import Random exposing (Generator)
{-| One row.
-}
type alias Sale =
{ id : String
, category : String
, item : String
, amount : Float
, score : Float
}
{-| `makeData length`, with a seed instead of faker's global one.
-}
makeData : Int -> Int -> List Sale
makeData seed length =
Random.step (sales length) (Random.initialSeed seed)
|> Tuple.first
sales : Int -> Generator (List Sale)
sales length =
List.range 0 (length - 1)
|> List.map (\index -> Random.map (\one -> { one | id = String.fromInt index }) sale)
|> List.foldr (Random.map2 (::)) (Random.constant [])
sale : Generator Sale
sale =
Random.map4
(\category ( adjective, noun ) amount score ->
{ id = ""
, category = category
, item = adjective ++ " " ++ noun
, amount = toFloat amount
, score = toFloat score
}
)
(Random.uniform "Hardware" [ "Software", "Services" ])
(Random.map2 Tuple.pair
(Random.uniform "Ergonomic" adjectives)
(Random.uniform "Chair" nouns)
)
(Random.int 25 5000)
(Random.int 60 100)
adjectives : List String
adjectives =
[ "Handcrafted", "Refined", "Practical", "Sleek", "Rustic", "Awesome", "Generic", "Licensed", "Fantastic", "Small" ]
nouns : List String
nouns =
[ "Table", "Keyboard", "Mouse", "Bench", "Lamp", "Bottle", "Shirt", "Gloves", "Ball", "Hat" ]