Expanding
Nested sub-rows with expand and collapse. Ports examples/react/expanding from TanStack Table.
Expanding.elm
module Expanding exposing (main)
{-| Ports `examples/react/expanding/src/main.tsx`.
`Table.withSubRows` hands the pipeline the nested rows, `expandedRowModel`
flattens the expanded branches into the row list, and each row indents itself
by `Table.rowDepth`. Selection rides along: toggling a parent selects its
sub-rows, and a parent whose children are all selected renders checked.
-}
import Browser
import Html exposing (Html, button, div, input, span, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (checked, class, colspan, placeholder, property, style, type_, value)
import Html.Events exposing (onClick, onInput)
import Json.Encode as Encode
import Shared.Controls as Controls
import Shared.People as People exposing (Person)
import Table
import Table.FilterFn as FilterFn
import Table.Value as Value exposing (Value)
-- CONFIG
config : Table.Config Person
config =
Table.config
[ Table.display "rowNumber"
|> Table.withHeader "#"
, Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First Name"
, Table.column "lastName" (.lastName >> maybeString)
|> Table.withHeader "Last Name"
|> Table.withFooter "lastName"
, Table.column "age" (.age >> toFloat >> Value.Number)
|> Table.withHeader "Age"
|> Table.withFilterFn FilterFn.between
, Table.column "visits" (.visits >> maybeNumber)
|> Table.withHeader "Visits"
, Table.column "status" (.status >> People.statusToString >> Value.String)
|> Table.withHeader "Status"
, Table.column "progress" (.progress >> toFloat >> Value.Number)
|> Table.withHeader "Profile Progress"
]
-- tell the table where nested rows live
|> Table.withSubRows People.subRowsOf
|> 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
{-| The columns whose filter is a numeric range, decided the way the React
example decides it: from the first row's value.
-}
isNumericColumn : String -> Bool
isNumericColumn columnId =
List.member columnId [ "age", "visits", "progress" ]
-- MODEL
lengths : List Int
lengths =
[ 100, 5, 3 ]
type alias Model =
{ state : Table.State
, seed : Int
, data : List Person
}
init : Model
init =
{ state = Table.initialState
, seed = 42
, data = People.makeData 42 lengths
}
type alias Stages =
{ core : Table.RowModel Person
, filtered : 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
filtered : Table.RowModel Person
filtered =
Table.filteredRowModel config model.state core
prePaginated : Table.RowModel Person
prePaginated =
filtered
|> Table.groupedRowModel config model.state
|> Table.sortedRowModel config model.state
|> Table.expandedRowModel config model.state
in
{ core = core
, filtered = filtered
, prePaginated = prePaginated
, paginated = Table.paginatedRowModel config model.state prePaginated
}
-- UPDATE
type Msg
= RegenerateData
| FilterChanged String Value
| ExpandToggled (Table.Row Person)
| AllRowsExpandToggled
| RowSelectionToggled (Table.Row Person)
| AllRowsSelectionToggled
| 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 lengths }
FilterChanged columnId v ->
{ model
| state =
Table.setColumnFilter config (stages model).core columnId v model.state
}
ExpandToggled row ->
{ model
| state =
Table.toggleExpanded config (stages model).prePaginated row Nothing model.state
}
AllRowsExpandToggled ->
{ model
| state =
Table.toggleAllRowsExpanded config
(stages model).prePaginated
Nothing
model.state
}
RowSelectionToggled row ->
{ model
| state =
Table.toggleRowSelected config (stages model).core row Nothing model.state
}
AllRowsSelectionToggled ->
{ model
| state =
Table.toggleAllRowsSelected config (stages model).filtered 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 model current) (Table.flatHeaders config state))
]
, tbody [] (List.map (viewRow model current) 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.paginated.rows) ++ " Rows") ]
, Controls.stateDump state
]
viewHeaderCell : Model -> Stages -> Table.Header Person -> Html Msg
viewHeaderCell model current header =
let
columnId : String
columnId =
Table.headerColumnId header
label : String
label =
Table.findColumn config columnId
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault columnId
in
th [ colspan (Table.headerColSpan header) ]
[ div []
[ if columnId == "firstName" then
span []
[ checkbox
(Table.getIsAllRowsSelected config model.state current.filtered)
(Table.getIsSomeRowsSelected model.state)
AllRowsSelectionToggled
, text " "
, button [ onClick AllRowsExpandToggled ]
[ text
(if Table.getIsAllRowsExpanded config model.state current.prePaginated then
"👇"
else
"👉"
)
]
, text (" " ++ label)
]
else
text label
, if Table.getCanFilter config columnId then
div [] [ viewFilter model columnId ]
else
text ""
]
]
viewFilter : Model -> String -> Html Msg
viewFilter model columnId =
let
filterValue : Value
filterValue =
Maybe.withDefault Value.Null (Table.getFilterValue model.state columnId)
in
if isNumericColumn columnId then
div [ class "filter-row" ]
[ rangeInput columnId filterValue 0 "Min"
, rangeInput columnId filterValue 1 "Max"
]
else
input
[ type_ "text"
, class "filter"
, placeholder "Search..."
, value (Value.toString filterValue)
, onInput (Value.String >> FilterChanged columnId)
]
[]
rangeInput : String -> Value -> Int -> String -> Html Msg
rangeInput columnId filterValue index hint =
input
[ type_ "number"
, class "filter"
, placeholder hint
, value (rangeEnd index filterValue)
, onInput (\typed -> FilterChanged columnId (setRangeEnd index typed filterValue))
]
[]
rangeEnd : Int -> Value -> String
rangeEnd index v =
case v of
Value.List items ->
List.drop index items
|> List.head
|> Maybe.map Value.toString
|> Maybe.withDefault ""
_ ->
""
setRangeEnd : Int -> String -> Value -> Value
setRangeEnd index typed v =
let
other : String
other =
rangeEnd (1 - index) v
in
if index == 0 then
Value.List [ Value.String typed, Value.String other ]
else
Value.List [ Value.String other, Value.String typed ]
viewRow : Model -> Stages -> Table.Row Person -> Html Msg
viewRow model current row =
let
displayIndex : Int
displayIndex =
Table.displayIndex config model.state current.prePaginated row
in
tr []
(List.map (viewCell model row displayIndex)
(Table.getAllCells config model.state row)
)
viewCell : Model -> Table.Row Person -> Int -> Table.Cell -> Html Msg
viewCell model row displayIndex cell =
if cell.columnId == "rowNumber" then
td [] [ text (String.fromInt (displayIndex + 1)) ]
else if cell.columnId == "firstName" then
td []
[ div
[ class "indent"
, style "padding-left" (String.fromInt (2 * Table.rowDepth row) ++ "rem")
]
[ checkbox
(Table.getIsRowSelected model.state row
|| (Table.getCanSelectSubRows config row
&& Table.getIsAllSubRowsSelected config model.state row
)
)
(Table.getIsSomeSelected config model.state row)
(RowSelectionToggled row)
, text " "
, if Table.getCanExpand config row then
button [ class "sortable", onClick (ExpandToggled row) ]
[ text
(if Table.getIsExpanded config model.state row then
"👇"
else
"👉"
)
]
else
text "🔵"
, text (" " ++ Value.toString cell.value)
]
]
else
td [] [ text (Controls.valueToString cell.value) ]
{-| `<IndeterminateCheckbox>`: `indeterminate` is a DOM property, not an
attribute, so it is set with `Html.Attributes.property`.
-}
checkbox : Bool -> Bool -> Msg -> Html Msg
checkbox isChecked isIndeterminate msg =
input
[ type_ "checkbox"
, checked isChecked
, property "indeterminate" (Encode.bool (isIndeterminate && not isChecked))
, onClick msg
]
[]
main : Program () Model Msg
main =
Browser.sandbox { init = init, update = update, view = view }