glin / reactable

Interactive data tables for R
https://glin.github.io/reactable
Other
627 stars 80 forks source link

Is there a way to not show nested rows if grouping only yields one row? #48

Open danielleoneil opened 4 years ago

danielleoneil commented 4 years ago

For example, in the below, since Col1 and Col2 for the first row both only have one value, I'd like them to appear on one line, rather than having the drop-downs to show the nested rows.

For the second row, the drop down for Col1 is perfect, but once you click into the nested rows, I wouldn't want to see the drop down for the Col2 values as there is only one value and having that nested row doesn't add more information.

df <- data.frame('Col1' = c(0,1,1,1,1,3,2,2,2,3,7,9,9), 'Col2' = c(0,1,17,18,19,3,2,20,21,22,7,9,15), 'Values' = c(3303,50575,39373,30303,760096,40484,50505,20202,8374,4837636,287375,133434,35083)) reactable(df, groupBy = c('Col1', 'Col2'), columns = list(Values = colDef(aggregate = 'sum')))

glin commented 4 years ago

Hi,

This isn't possible right now due to how grouping works in the React Table library. You can at least display values for single-row groups using a custom aggregate function:

library(reactable)

df <- data.frame(
  'Col1' = c(0,1,1,1,1,3,2,2,2,3,7,9,9),
  'Col2' = c(0,1,17,18,19,3,2,20,21,22,7,9,15),
  'Values' = c(3303,50575,39373,30303,760096,40484,50505,20202,8374,4837636,287375,133434,35083)
)

reactable(
  df,
  groupBy = c('Col1', 'Col2'),
  defaultColDef = colDef(
    # Default aggregate function: display the value if there's only one row in the group
    aggregate = JS("function(values) {
      if (values.length === 1) return values
    }")
  ),
  columns = list(
    Values = colDef(aggregate = 'sum')
  )
)

screenshot of table The first and fifth rows now show their Col2 values, but the groups are still there and the rows are still expandable. Unfortunately, it'll be tricky to ungroup single-row groups. I've looked into it before because I wanted to do the same, but found that it required too many hacks to get working properly.

For now, I'd recommend using conditional row details + nested tables if possible -- see example in https://github.com/glin/reactable/issues/33#issuecomment-596106592. It's more work to do the grouping manually, but at least you'd be able to control the row expansion.

In the future, this may also be possible through a feature like tree tables, where you'd have full control over the parent and child rows. You'd still have to do the grouping manually with a tree table, but it would probably be the best solution to this problem.

danielleoneil commented 4 years ago

Ah, makes sense - thank you so much for the thoughtful response. I'll try my hand at the using conditional row details + nested tables approach. Thanks again and thanks for the package - it's made my Shiny apps the envy of my team.

amanthapar commented 4 years ago

Hello, would it be possible to have the click event listen on the entire group row, so that users don't have to click on the arrow to open up the grouped row?

glin commented 4 years ago

@amanthapar You can use an onClick = "expand" click action to do that: https://glin.github.io/reactable/articles/examples.html#expand-on-click

Teebusch commented 1 year ago

Hi, I ran into this issue, too and I think I found a workaround for it. Disclaimer: I'm not sure how robust this is..

The trick is to

Here's a reprex:

library(MASS)
library(dplyr)
library(reactable)

# Mock data, 3 levels of grouping, 2 numerical non-grouping Variables per row
group_vars <- c("Manufacturer", "Type", "DriveTrain")
data <- MASS::Cars93[, c(group_vars, "Price", "MPG.city")]

# custom `rowClass` function -
# Rows without subrows get class `block-expandable` which we later use to
# hide the expander-button (the little triangle) and disable the event listener
# Expandable rows get the class `allow-expandable` instead. We usi it for
# styling.
row_class_fun <- JS("function(rowInfo) { return( rowInfo.subRows.length <= 1 ? 'block-expandable' : 'allow-expandable') }")

# Custom `aggregate` funtion for group columns -
# For groups with only one row, show that row's value in the parent row
aggregate_group_col <- JS("function(x) { return(x.length == 1 ? x : '') }")

# Optional: Custom `grouped` function for group columns -
# Suppress the `(n)` after the group name.
# Without it, it will not be uniform across the expandable and not-expandable
# rows and across group cols, i.e. some will have `(n)` and  others wont,
# so you probbly want some version of this.
grouped_group_col <- JS("function(cellInfo) { return cellInfo.value }")

# Completely optional: Trigger group expansion regardless of which column the
# user clicks (...or filter "click-to-expand"-columns via`column`)
on_click_fun <- JS("function(rowInfo, column) { if (rowInfo.subRows.length > 1) rowInfo.toggleRowExpanded() }")

# ColDef for the 3 group cols -
# use custom group and aggregate functions, add class for styling.
group_col_def <- colDef(
  grouped   = grouped_group_col,
  aggregate = aggregate_group_col,
  class     = "group-col"
)

# The table
rt <- reactable(
  data,
  highlight = TRUE,
  groupBy   = group_vars,
  rowClass  = row_class_fun,
  onClick   = on_click_fun,
  columns   = list(
    Manufacturer = group_col_def,
    Type         = group_col_def,
    DriveTrain   = group_col_def,
    # per-row variables.
    # Don't forget to define aggregate functions for value when grouped
    Price        = colDef("Max. Price", aggregate = "max"),
    MPG.city     = colDef("Avg. MPG", aggregate = "mean", format = colFormat(digits = 1))
  ),
)

# Custom CSS:
# A lot of this is pure styling.
# - hide the expand button in non-expandable rows (this is the only non-optional change)
# - change the cursor on non-expandable rows from pointer to 'regular'
# - color the expandable rows blue and make text bold
# - color the expander Button red
# - add some left padding to the table header and group cols to align all text,
#   regardless of whether there is an expander-button
custom_css <- "
  .rt-tr.block-expandable button.rt-expander-button {
    display: none;
    pointer-events: none;
  }

  .rt-tr.block-expandable .rt-td-expandable {
    cursor: auto;
  }

  .rt-tr.allow-expandable .rt-td-inner {
      color: steelblue;
      font-weight: bold;
  }

  .rt-td-expandable .rt-expander::after {
      border-top-color: tomato;
  }

  .rt-th-inner .rt-text-content {
    padding-left: 20px;
  }

  .rt-tr.block-expandable .group-col {
      padding-left: 20px
  }
"
# Custom JS:
# click events on the table are first registered by the `rt-td-inner` elements
# We add an event listener that intercepts these events and checks if any
# ancestor element (this would be the row it is in) has the class 'block-expandable'.
# (<https://developer.mozilla.org/en-US/docs/Web/API/Element/closest>)
# If so, we stop propagation of the click event.
# One could also check just the parent and grandparent element, as this should
# cover all "candidates", i.e.
# ```
# e.target.parentElement.classList.contains('block-expandable')  ||
# e.target.parentElement.parentElement.classList.contains('block-expandable')
# ````
# (also note the ID `my-table` that is used to select the wrapping DOM element.)
custom_js <- "
  document.addEventListener('DOMContentLoaded', function() {
    let table = document.getElementById('my-table');

    table.addEventListener('click', function(e) {
        if ( e.target.closest('div.rt-tr.block-expandable') !== null) {
          e.stopImmediatePropagation()
        }
    },
    useCapture = true  // ensures that listener fires before the regular reactable listeners
  )
  });
"

# Put it all together in a "browsable()"
# In a Shiny app the custom CSS and JS code would be added to the head
# somewhere else.
htmltools::tagList(
  htmltools::tags$head(
    htmltools::tags$style(custom_css),
    htmltools::tags$script(custom_js)
  ),
  htmltools::tags$div(id = "my-table", rt)
) |>
htmltools::browsable()

(This might also be a workaround for #168)