glin / reactable

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

`Reactable.setFilter` clears checked status for checkboxes #355

Closed uriahf closed 5 months ago

uriahf commented 5 months ago

Hi all, first thing: I'm in love with {reactable}! Thank you @glin for your hard word 🙏

I'm trying to combine external range filter and inline checkboxes, unfortunately everytime I change the value in the range slider the checkboxes status returns to be unchecked.

chrome-capture-2024-0-25

Inspiration:

reprex:

library(htmltools)
library(reactable)

data <- MASS::Cars93[1:5, c("Manufacturer", "Model", "Type", "AirBags", "Price")]

# Custom range input filter with label and value
rangeFilter <- function(tableId, columnId, label, min, max, value = NULL, step = NULL, width = "200px") {
  value <- if (!is.null(value)) value else min
  inputId <- sprintf("filter_%s_%s", tableId, columnId)
  valueId <- sprintf("filter_%s_%s__value", tableId, columnId)
  oninput <- paste(
    sprintf("document.getElementById('%s').textContent = this.value;", valueId),
    sprintf("Reactable.setFilter('%s', '%s', this.value)", tableId, columnId)
  )

  div(
    tags$label(`for` = inputId, label),
    div(
      style = sprintf("display: flex; align-items: center; width: %s", validateCssUnit(width)),
      tags$input(
        id = inputId,
        type = "range",
        min = min,
        max = max,
        step = step,
        value = value,
        oninput = oninput,
        onchange = oninput, # For IE11 support
        style = "width: 100%;"
      ),
      span(id = valueId, style = "margin-left: 8px;", value)
    )
  )
}

browsable(tagList(
  rangeFilter(
    "cars-ext-range",
    "Price",
    "Filter by Minimum Price",
    floor(min(data$Price)),
    ceiling(max(data$Price))
  ),

  reactable(
    data,
    columns = list(
      Price = colDef(
        html = TRUE,
        cell = JS(
          "function(cellInfo) {
        return `<input type = 'checkbox'>${cellInfo.value}`
      }"
        ))
    ),
    defaultPageSize = 5,
    elementId = "cars-ext-range"
  )
))
uriahf commented 5 months ago

Here is my approach for solving the problem, hope I'll get it right:

  1. set initial meta argument for each checkbox checked status, set everything to FALSE.
  2. Add id for each checkbox.
  3. Add onClick function that updates the original meta with Reactable.setMeta() once a checkbox is clicked.

On theory the filter should re-render the selected rows with the updated checked status for each checkbox. Sounds reasonable?

I'll try to write some code soon enough...

glin commented 5 months ago

Hey @uriahf, so the checkboxes get reset on filtering because the table is rerendering each cell that changes on any table state changes. Since that checkbox is just a plain unchecked <input type='checkbox'> in every row, that is what will be rerendered. If you want checkbox state to persist, you will need to hook it up to some external saved state. I think meta would work for saving that state, but let me know.

The render function might look something like (untested):

function(cellInfo, state) {
  const checked = state.meta.checked[cellInfo.index] ? 'checked' : ''
  return `<input type='checkbox' ${checked} onclick=${onclick}>${cellInfo.value}`
 }

Then you could attach an inline onclick handler to that checkbox, or do it with reactable's onClick.

uriahf commented 5 months ago

Sounds good, but are you sure about working with indexes and unnamed lists / arrays?

When I try to define the following function for the onclick events I get an error: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Missing_colon_after_property_id?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default

script <- sprintf("function synchronizeCheckboxes(checkbox) {
  Reactable.setMeta('cars-ext-range', 
prevMeta => {
  return { checked: { true true true true true } }
})}")

Correct me if I'm wrong, but working with meta on reachable requires named lists.

glin commented 5 months ago

If checked was supposed to be a map, then I think you'd need keys there, like { 0: true, 1: true, 2: true, 3: true, 4: true }

Here's a working example using the onClick handler. I also tried an inline onclick attribute, but getting complex multiline expressions in there is super janky. I'm using meta.checked as an array of true/false values here, where the index is the row index.

library(htmltools)
library(reactable)

data <- MASS::Cars93[1:5, c("Manufacturer", "Model", "Type", "AirBags", "Price")]

# Custom range input filter with label and value
rangeFilter <- function(tableId, columnId, label, min, max, value = NULL, step = NULL, width = "200px") {
  value <- if (!is.null(value)) value else min
  inputId <- sprintf("filter_%s_%s", tableId, columnId)
  valueId <- sprintf("filter_%s_%s__value", tableId, columnId)
  oninput <- paste(
    sprintf("document.getElementById('%s').textContent = this.value;", valueId),
    sprintf("Reactable.setFilter('%s', '%s', this.value)", tableId, columnId)
  )

  div(
    tags$label(`for` = inputId, label),
    div(
      style = sprintf("display: flex; align-items: center; width: %s", validateCssUnit(width)),
      tags$input(
        id = inputId,
        type = "range",
        min = min,
        max = max,
        step = step,
        value = value,
        oninput = oninput,
        onchange = oninput, # For IE11 support
        style = "width: 100%;"
      ),
      span(id = valueId, style = "margin-left: 8px;", value)
    )
  )
}

# Filter method that filters numeric columns by minimum value
filterMinValue <- JS("function(rows, columnId, filterValue) {
  return rows.filter(function(row) {
    return row.values[columnId] >= filterValue
  })
}")

browsable(tagList(
  rangeFilter(
    "cars-ext-range",
    "Price",
    "Filter by Minimum Price",
    floor(min(data$Price)),
    ceiling(max(data$Price))
  ),

  reactable(
    data,
    columns = list(
      Price = colDef(
        html = TRUE,
        cell = JS(
          "function(cellInfo, state) {
            const checked = state.meta.checked[cellInfo.index] ? 'checked' : ''
            return `<input type='checkbox' ${checked} onclick='${onclick}'>${cellInfo.value}`
          }"
        ),
        filterMethod = filterMinValue
      )
    ),
    meta = list(checked = list()),
    onClick = JS("(rowInfo, column) => {
      Reactable.setMeta('cars-ext-range', prevMeta => {
        const checked = [...prevMeta.checked]
        checked[rowInfo.index] = !checked[rowInfo.index]
        return { ...prevMeta, checked }
      })
    }"),
    defaultPageSize = 5,
    elementId = "cars-ext-range"
  )
))
uriahf commented 5 months ago

That's perfect, thanks!