Grouped Aggregation
Nested groups with merged aggregates at every level. Ports examples/react/grouped-aggregation from TanStack Table.
GroupedAggregation.elm
module GroupedAggregation exposing (main)
{-| Ports `examples/react/grouped-aggregation/src/main.tsx`.
Grouping with an aggregation function per column: `median` for age, `sum` for
visits, `mean` for progress. A group row's cells carry the aggregate
(`Table.cellIsAggregated`), nested groups merge their children's results, and
the footer runs the same functions over the pre-grouped rows through
`Table.aggregationValue`.
-}
import Browser
import Html exposing (Html, button, div, table, tbody, td, text, tfoot, th, thead, tr)
import Html.Attributes exposing (class, classList, colspan)
import Html.Events exposing (onClick)
import Shared.Controls as Controls
import Shared.People as People exposing (Person)
import Table
import Table.AggregationFn as AggregationFn
import Table.Value as Value exposing (Value)
-- CONFIG
config : Table.Config Person
config =
Table.config
[ Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First Name"
|> Table.withFooter "Grand Total"
-- override the value used for row grouping
|> Table.withGetGroupingValue
(\person _ ->
Value.String
(person.firstName ++ " " ++ Maybe.withDefault "" person.lastName)
)
, Table.column "lastName" (.lastName >> maybeString)
|> Table.withHeader "Last Name"
, Table.column "age" (.age >> toFloat >> Value.Number)
|> Table.withHeader "Age"
|> Table.withAggregationFn AggregationFn.median
, Table.column "visits" (.visits >> maybeNumber)
|> Table.withHeader "Visits"
|> Table.withAggregationFn AggregationFn.sum
, Table.column "status" (.status >> People.statusToString >> Value.String)
|> Table.withHeader "Status"
, Table.column "progress" (.progress >> toFloat >> Value.Number)
|> Table.withHeader "Profile Progress"
|> Table.withAggregationFn AggregationFn.mean
]
|> Table.withGetRowId (\person _ _ -> person.id)
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 =
2000
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
, preGrouped : 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
preGrouped : Table.RowModel Person
preGrouped =
Table.filteredRowModel config model.state core
prePaginated : Table.RowModel Person
prePaginated =
preGrouped
|> Table.groupedRowModel config model.state
|> Table.sortedRowModel config model.state
|> Table.expandedRowModel config model.state
in
{ core = core
, preGrouped = preGrouped
, prePaginated = prePaginated
, paginated = Table.paginatedRowModel config model.state prePaginated
}
-- UPDATE
type Msg
= RegenerateData
| GroupingToggled String
| ExpandToggled (Table.Row Person)
| 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 ] }
GroupingToggled columnId ->
{ model | state = Table.toggleGrouping columnId model.state }
ExpandToggled row ->
{ model
| state =
Table.toggleExpanded config (stages model).prePaginated row 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).prePaginated }
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" ]
[ div [ class "button-row" ]
[ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
, div [ class "spacer-sm" ] []
, table []
[ thead []
[ tr [] (List.map (viewHeaderCell state) (Table.flatHeaders config state)) ]
, tbody [] (List.map (viewRow model current) current.paginated.rows)
, tfoot []
(List.map (viewFooterRow 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.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.paginated.rows) ++ " Rows") ]
, Controls.stateDump state
]
viewHeaderCell : Table.State -> Table.Header Person -> Html Msg
viewHeaderCell state header =
let
columnId : String
columnId =
Table.headerColumnId header
in
th [ colspan (Table.headerColSpan header) ]
[ div []
[ if Table.getCanGroup config columnId then
button [ class "small", onClick (GroupingToggled columnId) ]
[ text
(if Table.getIsGrouped state columnId then
"🛑(" ++ String.fromInt (Table.getGroupedIndex state columnId) ++ ") "
else
"👊 "
)
]
else
text ""
, text (" " ++ headerLabel columnId)
]
]
headerLabel : String -> String
headerLabel columnId =
Table.findColumn config columnId
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault columnId
{-| The footer runs each column's aggregation function over the pre-grouped
rows, which is what `column.getAggregationValue()` with no arguments does.
-}
viewFooterRow : Stages -> Table.HeaderGroup Person -> Html Msg
viewFooterRow current group =
tr [] (List.map (viewFooterCell current) group.headers)
viewFooterCell : Stages -> Table.Header Person -> Html Msg
viewFooterCell current header =
let
columnId : String
columnId =
Table.headerColumnId header
in
th [ colspan (Table.headerColSpan header) ]
[ text
(case Table.findColumn config columnId |> Maybe.andThen Table.columnFooter of
Just footer ->
footer
Nothing ->
aggregatedText columnId
(Table.aggregationValue config current.preGrouped columnId)
)
]
viewRow : Model -> Stages -> Table.Row Person -> Html Msg
viewRow model current row =
tr []
(List.map (viewCell model current row) (Table.getAllCells config model.state row))
viewCell : Model -> Stages -> Table.Row Person -> Table.Cell -> Html Msg
viewCell model current row cell =
let
state : Table.State
state =
model.state
isGrouped : Bool
isGrouped =
Table.cellIsGrouped state row cell.columnId
isAggregated : Bool
isAggregated =
Table.cellIsAggregated config current.core state row cell.columnId
isPlaceholder : Bool
isPlaceholder =
Table.cellIsPlaceholder state row cell.columnId
in
td
[ classList
[ ( "grouped", isGrouped )
, ( "aggregated", isAggregated && not isGrouped )
, ( "placeholder", isPlaceholder && not isGrouped && not isAggregated )
]
]
[ if isGrouped then
button [ class "sortable", onClick (ExpandToggled row) ]
[ text
((if Table.getIsExpanded config state row then
"👇 "
else
"👉 "
)
++ cellText cell
++ " ("
++ Controls.formatInt (List.length (Table.rowSubRows row))
++ ")"
)
]
else if isAggregated then
-- the aggregated renderer of the cell
text (aggregatedText cell.columnId cell.value)
else if isPlaceholder then
text ""
else
text (cellText cell)
]
{-| `aggregatedCell` per column.
-}
aggregatedText : String -> Value -> String
aggregatedText columnId value =
case ( columnId, value ) of
( _, Value.Null ) ->
""
( "age", _ ) ->
Controls.round2 (Value.toNumber value)
( "visits", _ ) ->
Controls.formatInt (round (Value.toNumber value))
( "progress", _ ) ->
Controls.round2 (Value.toNumber value) ++ "%"
_ ->
Controls.valueToString value
cellText : Table.Cell -> String
cellText cell =
if cell.columnId == "progress" then
Controls.round2 (Value.toNumber cell.value) ++ "%"
else
Controls.valueToString cell.value
main : Program () Model Msg
main =
Browser.sandbox { init = init, update = update, view = view }