glin / reactable

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

Registering custom JS function on reactable output #286

Open Patrikios opened 1 year ago

Patrikios commented 1 year ago

Consider a case that I want to know the content of a cell of a column on mouseover (or perhaps in order to register a listener to listen for click and then display a modal dialogue that renders echarts as a fucntion of that variable over time).

A little more rudimentary, I include a reprex (under).

The canonical way in shiny is tho include custom JS functions is in tags$head or in session$onFlushed, however non of them reflects the case when the output takes a bit more time to render and the JS are listeners, like follows:

library(shiny)
library(reactable)

WAIT_TIME_SECONDS <- 3L

custom_js <-
  '
  setTimeout(function(){
    const els = document.querySelectorAll("#react .manufacturer");

    for(const el of els){

      el.addEventListener("mouseover", (event) => {

        let currElm = event.target;

        currElm.style.cursor="pointer";
        currElm.style.color = "#0099F9";

        let clos = currElm.closest(".manufacturer");
        if (clos) {
          let type_hovered_on = clos.firstChild.innerHTML;
          Shiny.setInputValue("hovering",
            { type:  type_hovered_on},
            { priority: "event" });
        };

      });

      el.addEventListener("mouseout", (event) => {
        let currElm = event.target;
        currElm.style.color = "";
      });

        };

  }, 2000)
  '

shinyApp(
  ui = fluidPage(
    tags$head(
      tags$script(HTML(
        custom_js
      ))
    ),
    reactableOutput("react"),
    br(),
    textOutput("type")
  ),
  server = function(input, output) {
    output$react <- renderReactable({
      Sys.sleep(WAIT_TIME_SECONDS)

      df <- MASS::Cars93[1:5, 1:4]

      reactable(df,
        columns = list(
          Manufacturer = colDef(
            class = "manufacturer"
          )
        )
      )
    })

    output$type <- renderText({
      paste0(
        "The Manufacturer you point at is: ",
        sQuote(
          input$hovering$type
        )
      )
    })
  }
)

In the above exmple, the global variable WAIT_TIME_SECONDS (above in example 3 seconds) needs to be smaller then the timeout value in custom_js's function wrapper setTimeout (2 seconds, hardcoded in the example above). In that case the JS is registered. When the value WAIT_TIME_SECONDS is bigger than setTimeout value, let me say 3 seconds, the JS listeners will not be part of the reactable output.

So the question is how to properly register a JS function(s) that are registered in the right time => after the reactable output render returns.

I though out the following solution leveraging on.exit coupled with shinyjs::runjs, however, intuitively, there must be a better, more formal solution:

library(shiny)
library(reactable)
library(shinyjs)

WAIT_TIME_SECONDS <- 5L

custom_js <-
  '
  setTimeout(function(){
    const els = document.querySelectorAll("#react .manufacturer");

    for(const el of els){

      el.addEventListener("mouseover", (event) => {

        let currElm = event.target;

        currElm.style.cursor="pointer";
        currElm.style.color = "#0099F9";

        let clos = currElm.closest(".manufacturer");
        if (clos) {
          let type_hovered_on = clos.firstChild.innerHTML;
          Shiny.setInputValue("hovering",
            { type:  type_hovered_on},
            { priority: "event" });
        };

      });

      el.addEventListener("mouseout", (event) => {
        let currElm = event.target;
        currElm.style.color = "";
        let clos = currElm.closest(".manufacturer");
        if (clos) {
          Shiny.setInputValue("hovering",
            { type:  ""},
            { priority: "event" });
        };
      });

        };

  }, 500)
  '

shinyApp(
  ui = fluidPage(
    useShinyjs(),
    reactableOutput("react"),
    br(),
    textOutput("type")
  ),
  server = function(input, output) {
    output$react <- renderReactable({
      on.exit(runjs(custom_js), add = TRUE)

      Sys.sleep(WAIT_TIME_SECONDS)

      df <- MASS::Cars93[1:5, 1:4]

      reactable(df,
        columns = list(
          Manufacturer = colDef(
            class = "manufacturer"
          )
        )
      )
    })

    output$type <- renderText({
      paste0(
        "The Manufacturer you point at is: ",
        sQuote(
          req(
            input$hovering$type
          )
        )
      )
    })
  }
)

In this secods reprex, the funciton gets registered after the render was run. Although, I have to note that a small buffer afterwards set by setTimeout for 500 miliseconds needs to be present in order for it to work as intended. WAIT_TIME_SECONDS does not play a role here, even if it is set to 10 seconds, the listener was registered.

What would be the proper way to registed custom JS function and listeners on the reactable use case like the above? Maybe the issue seems a bit more generic, however, in this case I am interested in how this is possible specifically for reactable API.

glin commented 1 year ago

Hey @Patrikios, thanks for posting these examples and details. If I understand correctly, there are two related but separate issues to talk about here:

  1. How do you run custom JavaScript that depends on the table being present on the page? This is an issue with all HTML widgets and Shiny outputs, and it's so common that the htmlwidgets package has an htmlwidgets::onRender() function that lets you run arbitrary JavaScript only after an HTML widget has been rendered. For an example, here's how I've adapted the first example app to use onRender():
    
    library(shiny)
    library(reactable)

custom_js <- 'function(widgetEl) { const els = widgetEl.querySelectorAll(".manufacturer");

for(const el of els) {

  el.addEventListener("mouseover", (event) => {

    let currElm = event.target;

    currElm.style.cursor="pointer";
    currElm.style.color = "#0099F9";

    let clos = currElm.closest(".manufacturer");
    if (clos) {
      let type_hovered_on = clos.firstChild.innerHTML;
      Shiny.setInputValue("hovering",
        { type:  type_hovered_on},
        { priority: "event" });
    };

  });

  el.addEventListener("mouseout", (event) => {
    let currElm = event.target;
    currElm.style.color = "";
  });

};

}'

shinyApp( ui = fluidPage( reactableOutput("react"), br(), textOutput("type") ), server = function(input, output) { output$react <- renderReactable({ df <- MASS::Cars93[1:5, 1:4]

  tbl <- reactable(
    df,
    searchable = TRUE,
    columns = list(
      Manufacturer = colDef(
        class = "manufacturer"
      )
    )
  )

  htmlwidgets::onRender(tbl, custom_js)
})

output$type <- renderText({
  paste0(
    "The Manufacturer you point at is: ",
    sQuote(
      input$hovering$type
    )
  )
})

} )


2. One problem with this approach though is that it attaches event listeners to cells only on the initial render. When a cell disappears, e.g. when filtering the table, those event listeners disappear and aren't re-added. It's probably fine if you don't make your table filterable, but a more reliable approach would be to use custom cell rendering to render an element that automatically attaches event listeners to itself when rendered.

The best supported way to do this would be to use the htmlwidgets package to create a custom widget, like the sparkline widget. reactable ensures that HTML widgets are properly initialized when rendered or re-rendered later.

But creating a custom HTML widget just for a one-off table may also be overkill, so another less-documented but supported way to do this would be to create a custom React component. It'll require more JavaScript/React knowledge, but will be a simpler way to render dynamic cell content that always works. Here's an example of a custom "HoverableCell" React component that sends hover state back to Shiny:
```r
library(shiny)
library(reactable)

custom_js <-
  '
  function HoverableCell({ children }) {
    const [color, setColor] = React.useState("")
    const onMouseEnter = event => {
      setColor("#0099F9")
      Shiny.setInputValue("hovering", { type: children }, { priority: "event" })
    }
    const onMouseLeave = event => {
      setColor("")
    }
    const style = { cursor: "pointer", color }
    return React.createElement("div", { onMouseEnter, onMouseLeave, style }, children)
  }'

shinyApp(
  ui = fluidPage(
    tags$head(
      tags$script(HTML(
        custom_js
      ))
    ),
    reactableOutput("react"),
    br(),
    textOutput("type")
  ),
  server = function(input, output) {
    output$react <- renderReactable({
      df <- MASS::Cars93[1:5, 1:4]

      reactable(
        df,
        searchable = TRUE,
        columns = list(
          Manufacturer = colDef(
            cell = JS("function(cellInfo) {
              return React.createElement(HoverableCell, null, cellInfo.value)
            }")
          )
        )
      )
    })

    output$type <- renderText({
      paste0(
        "The Manufacturer you point at is: ",
        sQuote(
          input$hovering$type
        )
      )
    })
  }
)

Similar questions have come up a few times before, so this will have to be added to the docs at some point. For now, there's only a light mention of rendering custom React elements in the custom filter inputs doc.

Patrikios commented 1 year ago

Thanks for the attentive answer, including the more realistic case when the table gets filtered or sorted.

I was aware of registering the cell JS function directly in the reactable, in the cell argument, however the slot was aready taken by an R function (see example under).

What follows is my complete usecase for the clashing R vs JS functions on a column, resulting in me trying the register the JS from 'outside' of reactable (excuse my gibberish JS, it serves the purpose of showcasing my intention nevertheless). The R function packs the Manufacture and Model into one column div, JS on 'filterMethod' enables filter on the above mentioned. Where would I conveniently insert the JS function resulting in Shiny setting the hover input in the following case (also available on resort | filtering)?

Would you proceed with a rewrite of the R function into JS or is it possible to provide multiple functions simultaniously (in this case R and JS)?

library(shiny)
library(shinyjs)
library(reactable)

df <- local({
  temp <- MASS::Cars93[1:5, 1:4]
  factors <- vapply(temp, is.factor, logical(1L)) |>
    Filter(isTRUE, x = _) |>
    names()
  temp[, factors] <- lapply(temp[, factors], as.character)
  temp
})

custom_js <-
  'setTimeout(function(){

  const els = document.querySelectorAll("#react div.manufacturer > .rt-td-inner");

  for(const el of els){

    el.addEventListener("mouseover", (event) => {
      let currElm = event.target;
      let clos = currElm.closest(".manufacturer>div");
      if (clos) {

        currElm.style.cursor="pointer";
        clos.style.color = "#0099F9";

        let hovered_on = currElm.closest("div>.manufacturer>div").firstChild.children[1].innerHTML;
        console.log(hovered_on);
        Shiny.setInputValue("hovering",
            { type:  hovered_on},
            { priority: "event" });

      }
    });

    el.addEventListener("mouseout", (event) => {
      let currElm = event.target;
      let clos = currElm.closest(".manufacturer>div");
      if (clos) {
        clos.style.color = "";
        Shiny.setInputValue("hovering",
            { type:  ""},
            { priority: "event" });
      }
    });
  };

  }, 100)'

shinyApp(
  ui = fluidPage(
    useShinyjs(),
    selectInput(
      "manufacturer",
      "Choose manufacturer",
      c("All", sort(unique(df$Manufacturer))),
      "All"
    ),
    reactableOutput("react"),
    br(),
    textOutput("type")
  ),
  server = function(input, output) {
    output$react <- renderReactable({
      temp <- if (input$manufacturer == "All") {
        df
      } else {
        df[df$Manufacturer == input$manufacturer, ]
      }

      on.exit(shinyjs::runjs(custom_js), add = TRUE)

      reactable(
        temp,
        searchable = TRUE,
        columns = list(
          Manufacturer = colDef(
            class = "manufacturer",
            filterable = TRUE,
            # Filter by case-insensitive text match - Manufacturer or Model
            filterMethod = JS("function(rows, columnId, filterValue) {
                                return rows.filter(function(row) {
                                  return row.values[columnId].indexOf(filterValue) !== -1 |
                                  row.values['Model'].toLowerCase().indexOf(filterValue.toLowerCase()) !== -1
                                })
                              }"),
            # Show 'Manufacturer' and 'Model' under column 'Manufacturer'
            cell = function(value, index) {
              manufacturer <- temp$Manufacturer[index]
              model <- temp$Model[index]
              div(
                div(style = "font-weight: 600", value),
                div(style = "font-size: 0.85rem", paste(manufacturer, model, sep = "-"))
              )
            },
            sticky = "left",
          ),
          Model = colDef(
            show = FALSE
          )
        )
      )
    })

    output$type <- renderText({
      paste0(
        "The Manufacturer you point at is: ",
        sQuote(
          input$hovering$type
        )
      )
    })
  }
)
glin commented 1 year ago

I would definitely rewrite the R function to JavaScript. There is a new feature in the development version called custom metadata that lets you pass data from R directly to JavaScript, meant for the case where you want to mix R and JavaScript. But since this example app is only building HTML, which is straightforward to do in JavaScript/React, you might as well switch completely to JavaScript to keep all the logic organized in one place.

If your real app has more complicated code that's only possible in R, then it would make sense to use the custom metadata feature. But for this example, you can expand on my previous React example to additionally generate the custom manufacturer/model HTML like this. Here, I've changed the previous "HoverableCell" into a more specific "ManufacturerCell" component that takes manufacturer and model props, builds the nested divs, and attaches the mouse listeners like before.

library(shiny)
library(reactable)

df <- local({
  temp <- MASS::Cars93[1:5, 1:4]
  factors <- vapply(temp, is.factor, logical(1L)) |>
    Filter(isTRUE, x = _) |>
    names()
  temp[, factors] <- lapply(temp[, factors], as.character)
  temp
})

custom_js <-
  '
  function ManufacturerCell({ manufacturer, model }) {
    const fullModel = manufacturer + "-" + model

    const [color, setColor] = React.useState("")
    const onMouseEnter = event => {
      setColor("#0099F9")
      Shiny.setInputValue("hovering", { type: fullModel }, { priority: "event" })
    }
    const onMouseLeave = event => {
      setColor("")
      Shiny.setInputValue("hovering", { type: "" }, { priority: "event" })
    }
    const style = { cursor: "pointer", color }

    const mfrElement = React.createElement("div", { style: { fontWeight: 600 } }, manufacturer)
    const modelElement = React.createElement("div", { style: { fontSize: "0.85rem" } }, fullModel)
    return React.createElement("div", { onMouseEnter, onMouseLeave, style }, [
      mfrElement,
      modelElement
    ])
  }'

shinyApp(
  ui = fluidPage(
    tags$head(
      tags$script(HTML(
        custom_js
      ))
    ),
    selectInput(
      "manufacturer",
      "Choose manufacturer",
      c("All", sort(unique(df$Manufacturer))),
      "All"
    ),
    reactableOutput("react"),
    br(),
    textOutput("type")
  ),
  server = function(input, output) {
    output$react <- renderReactable({
      temp <- if (input$manufacturer == "All") {
        df
      } else {
        df[df$Manufacturer == input$manufacturer, ]
      }

      reactable(
        temp,
        searchable = TRUE,
        columns = list(
          Manufacturer = colDef(
            class = "manufacturer",
            filterable = TRUE,
            # Filter by case-insensitive text match - Manufacturer or Model
            filterMethod = JS("function(rows, columnId, filterValue) {
                                return rows.filter(function(row) {
                                  return row.values[columnId].indexOf(filterValue) !== -1 |
                                  row.values['Model'].toLowerCase().indexOf(filterValue.toLowerCase()) !== -1
                                })
                              }"),
            # Show 'Manufacturer' and 'Model' under column 'Manufacturer'
            cell = JS("function(cellInfo) {
              const manufacturer = cellInfo.value
              const model = cellInfo.row['Model']
              return React.createElement(ManufacturerCell, { manufacturer, model })
            }"),
            sticky = "left",
          ),
          Model = colDef(
            show = FALSE
          )
        )
      )
    })

    output$type <- renderText({
      paste0(
        "The Manufacturer you point at is: ",
        sQuote(
          input$hovering$type
        )
      )
    })
  }
)

I think at this point, this use case is so advanced that you have to go all-in on JavaScript and React for a good solution. The JavaScript in that example looks very brittle, reaching into table internals like .rt-td-inner that aren't documented or guaranteed to remain stable. So if all your custom JS is in one self-contained React component that works even outside of reactable, it should be both more reliable and easier to update/maintain in the future.

Patrikios commented 1 year ago

Thnx for ur thoughts, useful!