Search code examples
javascriptrshinyreactable

How can I correctly aggregate nested rows with reactable in R?


I am working on a Shiny app where the reactable package has been very useful because of the nested structure of my data. It allows me to collapse and summarize rows within a higher-level category, and only expand to reveal the 'subrows' if/when desired.

For just 1 nested level (e.g., car models within makes), the aggregation functions (unique, count etc.) from reactable work just fine out of the box. However when adding extra levels of nested-ness, things break down and even aggregators like unique present repeated values (!). I suspect this is because the subcategories are not all pooled together in a flat structure with only 1 aggregation step performed on all of them, but rather unique values remain subcategory-specific and then just get concatenated together, leading to repetition. This issue affects other aggregators too, not just unique.

I'm adding a MWE below in R, as I've been unable to fix this. Since JS is also far from being my strong suit, I've been unable to insert any JS to fix this more flexibly, as is suggested here. How can I tweak the aggregators below to get correctly displayed output (i.e., no repetition)?

library(shiny)
library(shinydashboard)
library(reactable)
library(stringr)

if (interactive()) {
  
  ui <- shinyUI(basicPage(
    box(title = "mtcars data",
        width = 12,
        reactableOutput("car_tab")
    )
  ))
  
  server <- function(input, output, session) {
    output$car_tab <- renderReactable({
      
      df <- mtcars
      df$make <- str_extract(rownames(df), "[[:alpha:]]+")
      df$model <- rownames(df)
      rownames(df) <- NULL
      df <- df[ , c("make", "model", "mpg", "cyl", "disp", "hp", "drat", "wt", "qsec", "vs", "am", "gear", "carb")]
      
      reactable(df,
                groupBy = c("cyl", "am", "make"),
                columns = list(
                  cyl = colDef(name = "Number of cylinders"),
                  am = colDef(name = "Transmission",
                              aggregate = "frequency"),
                  make = colDef(name = "Make",
                                aggregate = "frequency"),
                  model  = colDef(name = "Model",
                                  aggregate = "unique"),
                  mpg = colDef(name = "Miles/gallon",
                               aggregate = "mean",
                               format = colFormat(digits = 2)),
                  disp = colDef(name = "Displacement"),
                  hp = colDef(name = "Horsepower"),
                  drat = colDef(name = "Rear axle ratio"),
                  wt = colDef(name = "Weight"),
                  qsec = colDef(name = "1/4 mile time"),
                  vs = colDef(name = "Engine",
                              aggregate = "count"),
                  gear = colDef(name = "Number of forward gears"),
                  carb = colDef(name = "Number of carburetors")
                )
      )
    })
  }
  
  shinyApp(ui = ui, server = server)
  
}

Solution

  • I think this can be solved by using aggregators as you are using them just now and then supplying a custom JavaScript renderer through aggregated parameter of colDef. The idea is that reactable will do aggregation through built in operators and will use the custom renderer to render output in aggregated cells.

    The custom JavaScript renderer should take a string that looks like 0, 0(2), 1(6), 1(2) and do some string manipulation to produce an output like 0(3), 1(8).

    If the JavaScript function is defined and saved in an R variable like this, it can be reused:

    jsRenderer <- "
        function(cellInfo) {
            const handleBrackets = (item) => {
                const currentName = item.replace(/\\([0-9]+\\)/g, '')
                const currentCountsArr = item.match(/\\(([0-9]+)\\)/)
                let currentCount = 1
                if (currentCountsArr && currentCountsArr.length === 2) {
                    currentCount = parseInt(currentCountsArr[1], 10)
                }
                return {
                    currentName,
                    currentCount
                }
            }
    
            const getCounts = (input) => {
                const trimmedInput = input.replace(/\\s+/g, '')
                const items = trimmedInput.split(',')
                const namesWithoutBrackets = trimmedInput.replace(/\\(([0-9]+)\\)/g, '').split(',')
                const itemObj = items.reduce((prev, current, index) => {
                    const itemWithoutBrackets = handleBrackets(current)
                    let {
                        currentName,
                        currentCount
                    } = itemWithoutBrackets
    
                    if (namesWithoutBrackets.indexOf(currentName) !== index) {
                        currentCount += prev[currentName]
                    }
    
                    return {
                        ...prev,
                        ...{
                            [currentName]: currentCount
                        }
                    }
    
                }, {})
    
                const stringToSanitize = Object.entries(itemObj).reduce((prevString, currentKeyValue) => {
                    return prevString.concat(`${currentKeyValue[0]}(${currentKeyValue[1]}), `)
                }, '')
    
                return stringToSanitize.slice(0, -2)
            }
    
            return (getCounts(cellInfo.value))
        }
    "
    

    You can then supply this rendered to colDef like this:

    colDef(name = "Transmission", aggregate = "frequency", aggregated = jsRenderer)
    

    In your MWE, the result is displayed like this:

    Merc(2),
    Toyota(2),
    Datsun(1),
    Fiat(2),
    Honda(1),
    Porsche(1),
    Lotus(1),
    Volvo(1)