Search code examples
javascriptrshinyshiny-reactivityrhandsontable

Rhandsontable/shiny/ undo


I have a shiny app with an integrated rhandsontable, I have a "undo" button which undoes all changes at once (so for example if I changed 5 values, then click "undo" all 5 values are reverted simultaneously)

Is there any possibility to implement a second version of the undo button so that the changes are reverted one by one, so undo would only revert the single most recent change, i.e. after changing 3 values, I would have to click the undo button 3 times to revert all changes?

library(shiny)
library(rhandsontable)

change_hook <- "function(el,x) {
var hot = this.hot;  
var cellChanges = [];

var changefn = function(changes,source) { 
if (source === 'edit' || source === 'undo' || source === 'autofill' || source === 'paste') {
row = changes[0][0];
col = changes[0][1];
oldval = changes[0][2];
newval = changes[0][3];

if (oldval !== newval) {
  var cell = hot.getCell(row, col);
  cell.style.background = 'cyan';
  cellChanges.push({'rowid':row, 'colid':col});
}
}
}

var renderfn = function(isForced) {

for(i = 0; i < cellChanges.length; i++)
{

var rowIndex = cellChanges[i]['rowid'];
var columnIndex = cellChanges[i]['colid'];

var cell = hot.getCell(rowIndex, columnIndex);
cell.style.background = 'cyan';

}


}

var loadfn = function(initialLoad) {

for(i = 0; i < cellChanges.length; i++)
    {
      var rowIndex = cellChanges[i]['rowid'];
      var columnIndex = cellChanges[i]['colid'];

      var cell = hot.getCell(rowIndex, columnIndex);

      cell.style.background = 'white';

    }
cellChanges = []

}


hot.addHook('afterChange', changefn);
hot.addHook('afterRender', renderfn);
hot.addHook('afterLoadData', loadfn);


}  "


ui <- div(actionButton(inputId = "undo_button",label = "undo")
          ,rHandsontableOutput(outputId="mtcars"))


server <- function(input, output, session) {
  
  
  undo <- reactiveVal(0)
  output$mtcars <- renderRHandsontable({
    r = undo()
    rht = rhandsontable(mtcars,reset=r,stretchH="all",height=300)
    undo(0)
    htmlwidgets::onRender(rht,change_hook)
  })
  
  observeEvent(input$undo_button,
               {
                 undo(1)
               })
}

 shinyApp(ui, server)

Solution

  • I don't think you need such a button as the functionality is already implemented by {rhandsontable} out of the box:

    • Ctrl + z: Undo
    • Ctrl + y: Redo

    What I suggest, however, is a rethinking of what to render and when to re-render.

    In this example, I show how you can achieve both of the functionalities you're after:

    • Undo (and redo) changes, one by one. Of course, using Ctrl + z & Ctrl + y, respectively.
    • An undo button which reverts all changes made at once.

    An example showing the functionalities

    library(rhandsontable)
    library(shiny)
    
    ui <- fluidPage(
      titlePanel("Handsontable"),
      rHandsontableOutput("hot", height = 500),
      actionButton(
        inputId = "undo_all",
        label = "Undo all changes",
        icon = icon(
          name = "arrow-rotate-left",
          class = "fa-sharp fa-light"
        ),
        style = "margin-top: 20px;"
      )
    )
    
    server <- function(input, output, session) {
      # reactive containing the original dataset:
      r_original <- reactive({ iris })
      
      # reactive value to track changes to the dataset:
      rv_current <- reactiveVal(value = NULL)
      
      # in case the original dataset is changed, update 'rv_current':
      observeEvent(r_original(), {
        rv_current(r_original())
      })
      
      # in case the rhandsontable is edited, update 'rv_current':
      observeEvent(input$hot, {
        current <- hot_to_r(input$hot)
        rv_current(current)
      })
      
      output$hot <- renderRHandsontable({
        rhandsontable(
          data = r_original(), # NOTE: Render 'r_original()', NOT 'rv_current()'
          readOnly = FALSE,
          useTypes = TRUE,
          stretchH = "all"
        )
      }) |> 
        bindEvent(r_original(), input$undo_all)
      # The bindEvent tells shiny to only re-render the table if 'r_original()'
      # changes or when 'undo_all' button is clicked.
      
      # You can now use 'rv_current()' for any calculations on the newest state of
      # the table
    }
    
    shinyApp(ui, server)