2

I am working on a shiny application that allows users to enter comments about an observation. The comments are then saved in a SQL database on the back end. The code below is a working representation of my current application.

What is happening is the tables load with the subset of Cylinder = 4 (the radio buttons), the user can save comments, got to Cylinder = 6, save comments, and then Cylinder = 8, and save comments. But if I ever change the cylinder back to a value that I've already saved comments at, the text inputs are unbound and no comments are saved. In order to restore the functionality, I have to restart the application. I've found that irritates my users.

What do I need to do to make sure I can continue to save comments if I go back to a Cylinder value I've already used?

I'm sorry that it isn't a very concise example. When you enter a comment, the console will print the number of comments saved, and display the data frame that was altered so you can compare what is showing in the application.

library(shiny)
library(DT)
library(dplyr)

mtcars$comment <- rep("", nrow(mtcars))
mtcars$row_id <- seq_len(nrow(mtcars))
AppData <- split(mtcars, mtcars[c("cyl", "am")])

# Makes a text input column out of a data frame
make_inputtable <- function(df){
  df$comment <- 
    mapply(
      function(comment, id){
        as.character(textInput(inputId = sprintf("txt_comment_%s", id), 
                               label = "", 
                               value = comment))
      }, 
      comment = df$comment, 
      id = df$row_id, 
      SIMPLIFY = TRUE)

  df
}

ui <- shinyUI(
  fluidPage(
    radioButtons(inputId = "rdo_cyl", 
                 label = "Cylinders", 
                 choices = sort(unique(mtcars$cyl)), 
                 inline = TRUE), 

    h3("Automatic"), 
    actionButton(inputId = "btn_save_automatic", 
                 label = "Save Comments"),
    DT::dataTableOutput("am0"),

    hr(),

    h3("Manual"), 
    actionButton(inputId = "btn_save_manual", 
                 label = "Save Comments"),
    DT::dataTableOutput("am1"), 

    # unbind a datatable. Needs to be done before a table is redrawn.
    tags$script(HTML(
      "Shiny.addCustomMessageHandler('unbind-DT', function(id) {
          Shiny.unbindAll($('#'+id).find('table').DataTable().table().node());
          })"))
  )
)


server <- shinyServer(function(input, output, session){
  reactiveData <- reactiveValues(
    am0_cyl4 = AppData[["4.0"]],
    am0_cyl6 = AppData[["6.0"]], 
    am0_cyl8 = AppData[["8.0"]],
    am1_cyl4 = AppData[["4.1"]],
    am1_cyl6 = AppData[["6.1"]], 
    am1_cyl8 = AppData[["8.1"]]
  ) 

  # Reactive Objects ------------------------------------------------

  ref0 <- reactive({
    sprintf("am0_cyl%s", input$rdo_cyl)
  })

  data0 <- reactive({
    reactiveData[[ref0()]]
  })

  ref1 <- reactive({
    sprintf("am1_cyl%s", input$rdo_cyl)
  })

  data1 <- reactive({
    reactiveData[[ref1()]]
  })

  # Event Observers -------------------------------------------------

  observeEvent(
    input$btn_save_automatic, 
    {
      in_field <- names(input)[grepl("^txt_comment_", names(input))]
      in_field_id <- sub("^txt_comment_", "", in_field)
      in_field_id <- as.numeric(in_field_id)
      in_field_id <- in_field_id[in_field_id %in% data0()$row_id]

      exist_frame <- data0()[c("row_id", "comment")]
      new_frame <- 
        data.frame(
          row_id = in_field_id, 
          comment = vapply(in_field_id, 
                           function(id){ input[[sprintf("txt_comment_%s", id)]]}, 
                           character(1)), 
          stringsAsFactors = FALSE)

      Compare <- left_join(exist_frame, 
                           new_frame, 
                           by = "row_id", 
                           suffix = c("_exist", "_new")) %>% 
        filter(comment_exist != comment_new)

      message(sprintf("* %s comment(s) saved", nrow(Compare)))

      # Only perform the save operations if there are changes to be made.
      if (nrow(Compare)){
        session$sendCustomMessage("unbind-DT", "am0")

        for(i in seq_len(nrow(Compare))){
          row <- Compare$row_id
          reactiveData[[ref0()]]$comment[reactiveData[[ref0()]]$row_id == row] <- 
            input[[sprintf("txt_comment_%s", row)]]
        }
        print(data0())
      }

    }
  )

  # Very similar to btn_save_automatic
  observeEvent(
    input$btn_save_manual, 
    {
      in_field <- names(input)[grepl("^txt_comment_", names(input))]
      in_field_id <- sub("^txt_comment_", "", in_field)
      in_field_id <- as.numeric(in_field_id)
      in_field_id <- in_field_id[in_field_id %in% data1()$row_id]

      exist_frame <- data1()[c("row_id", "comment")]
      new_frame <- 
        data.frame(
          row_id = in_field_id, 
          comment = vapply(in_field_id, 
                           function(id){ input[[sprintf("txt_comment_%s", id)]]}, 
                           character(1)), 
          stringsAsFactors = FALSE)

      Compare <- left_join(exist_frame, 
                           new_frame, 
                           by = "row_id", 
                           suffix = c("_exist", "_new")) %>% 
        filter(comment_exist != comment_new)

      message(sprintf("* %s comment(s) saved", nrow(Compare)))

      # Only perform the save operations if there are changes to be made.
      if (nrow(Compare)){
        session$sendCustomMessage("unbind-DT", "am1")

        for(i in seq_len(nrow(Compare))){
          row <- Compare$row_id
          reactiveData[[ref1()]]$comment[reactiveData[[ref1()]]$row_id == row] <- 
            input[[sprintf("txt_comment_%s", row)]]
        }
        print(data1())
      }

    }
  )


  # Output Objects --------------------------------------------------

  output$am0 <-
    DT::renderDataTable({
      make_inputtable(data0()) %>%
        datatable(escape = -13, 
                  options = list(preDrawCallback = DT::JS('function() { Shiny.unbindAll(this.api().table().node()); }'),
                                 drawCallback = DT::JS('function() { Shiny.bindAll(this.api().table().node()); } ')))
    })

  output$am1 <-
    DT::renderDataTable({
      make_inputtable(data1()) %>%
        datatable(escape = -13, 
                  options = list(preDrawCallback = DT::JS('function() { Shiny.unbindAll(this.api().table().node()); }'),
                                 drawCallback = DT::JS('function() { Shiny.bindAll(this.api().table().node()); } ')))
    })


})

shinyApp(ui = ui, server = server)

Edits and updates

editable data tables are a potential solution, but would require upgrading our package library. We are currently using R 3.4.1 with shiny 1.0.4 and DT 0.2.12.

Yes, that's comparatively ancient. But the cost of upgrading is substantial given the sensitivity of the reports supported by this application and the quality assurance required by any upgrade.

Benjamin
  • 16,897
  • 6
  • 45
  • 65
  • Is there any specific reason why you are using `textInput`s? I'd create a comment column and make it `editable` - see chapter 2.3 [here](https://rstudio.github.io/DT/). Is that an option? – ismirsehregal Feb 10 '20 at 12:28
  • the `editable` argument is not in the version of `DT` that I am using. Upgrading is an option, but will be time consuming and somewhat costly. If I'm unable to resolve this in the current code base, an upgrade will be in order. I'll update the question with the versions we are running. – Benjamin Feb 10 '20 at 12:53
  • [Here](https://stackoverflow.com/questions/70006107/dt-link-binding-is-lost-after-re-rendering-the-table/70095544#70095544) is a related question and another workaround. – ismirsehregal Nov 25 '21 at 12:55

2 Answers2

2

Putting aside your version restrictions, here is how I'd approach this with the latest library(DT) version (Hopefully useful for future readers and maybe someday you will also update):

Edit: now using dataTableProxy to avoid re-rendering.

library(shiny)
library(DT)

ui <- shinyUI(
  fluidPage(
    radioButtons(inputId = "rdo_cyl", 
                 label = "Cylinders", 
                 choices = sort(unique(mtcars$cyl)), 
                 inline = TRUE), 
    h3("Automatic"), 
    actionButton(inputId = "btn_save_automatic", 
                 label = "Save Comments"), p(),
    DTOutput("am0"),
    hr(),
    h3("Manual"), 
    actionButton(inputId = "btn_save_manual", 
                 label = "Save Comments"), p(),
    DTOutput("am1")
  )
)

server <- shinyServer(function(input, output, session){
  globalData <- mtcars
  globalData$comment <- rep("", nrow(mtcars))
  globalData$row_id <- seq_len(nrow(mtcars))

  diabledCols <- grep("comment", names(globalData), invert = TRUE)
  AppData <- reactiveVal(globalData)

  automaticAppData <- reactive({
    AppData()[AppData()[["cyl"]] %in% input$rdo_cyl & AppData()[["am"]] %in% "0", ]
  })

  manualAppData <- reactive({
    AppData()[AppData()[["cyl"]] %in% input$rdo_cyl & AppData()[["am"]] %in% "1", ]
  })

  output$am0 <- DT::renderDT(
    # isolate: render only once
    expr = {isolate(automaticAppData())},
    editable = list(target = "cell", disable = list(columns = diabledCols))
  )

  output$am1 <- DT::renderDT(
    # isolate: render only once
    expr = {isolate(manualAppData())},
    editable = list(target = "cell", disable = list(columns = diabledCols))
  )

  observeEvent(input$btn_save_automatic, {
    info = input$am0_cell_edit
    str(info)
    i = automaticAppData()$row_id[[info$row]]
    j = info$col
    v = info$value
    globalData[i, j] <<- DT::coerceValue(v, globalData[i, j])
    AppData(globalData)
    # update database...
  })

  observeEvent(input$btn_save_manual, {
    info = input$am1_cell_edit
    str(info)
    i = manualAppData()$row_id[[info$row]]
    j = info$col
    v = info$value
    globalData[i, j] <<- DT::coerceValue(v, globalData[i, j])
    AppData(globalData)
    # update database...
  })

  am0Proxy <- dataTableProxy("am0")
  am1Proxy <- dataTableProxy("am1")

  observeEvent(automaticAppData(), {
    replaceData(am0Proxy, automaticAppData(), resetPaging = FALSE)
  })

  observeEvent(manualAppData(), {
    replaceData(am1Proxy, manualAppData(), resetPaging = FALSE)
  })

})

shinyApp(ui = ui, server = server)

Result

Here are some related infos.


Update for DT Version 0.2

Here is another solution closer to your initial code. I'm using isolate(), dataTableProxy() and replaceData() which are available since DT version 0.2 to avoid re-rendering the table, which resolves the binding issue and should be faster.

Another problem in your code was that you called session$sendCustomMessage("unbind-DT", "am0") twice instead of using it for "am1".

library(shiny)
library(DT)
library(dplyr)

mtcars$comment <- rep("", nrow(mtcars))
mtcars$row_id <- seq_len(nrow(mtcars))
AppData <- split(mtcars, mtcars[c("cyl", "am")])

# Makes a text input column out of a data frame
make_inputtable <- function(df){
  df$comment <- 
    mapply(
      function(comment, id){
        as.character(textInput(inputId = sprintf("txt_comment_%s", id), 
                               label = "", 
                               value = comment))
      }, 
      comment = df$comment, 
      id = df$row_id, 
      SIMPLIFY = TRUE)

  df
}

ui <- shinyUI(
  fluidPage(
    radioButtons(inputId = "rdo_cyl", 
                 label = "Cylinders", 
                 choices = sort(unique(mtcars$cyl)), 
                 inline = TRUE), 

    h3("Automatic"), 
    actionButton(inputId = "btn_save_automatic", 
                 label = "Save Comments"),
    DT::dataTableOutput("am0"),

    hr(),

    h3("Manual"), 
    actionButton(inputId = "btn_save_manual", 
                 label = "Save Comments"),
    DT::dataTableOutput("am1"),

    # unbind a datatable. Needs to be done before a table is redrawn.
    tags$script(HTML(
      "Shiny.addCustomMessageHandler('unbind-DT', function(id) {
          Shiny.unbindAll($('#'+id).find('table').DataTable().table().node());
          })"))
  )
)


server <- shinyServer(function(input, output, session){
  reactiveData <- reactiveValues(
    am0_cyl4 = AppData[["4.0"]],
    am0_cyl6 = AppData[["6.0"]], 
    am0_cyl8 = AppData[["8.0"]],
    am1_cyl4 = AppData[["4.1"]],
    am1_cyl6 = AppData[["6.1"]], 
    am1_cyl8 = AppData[["8.1"]]
  ) 

  # Reactive Objects ------------------------------------------------

  ref0 <- reactive({
    sprintf("am0_cyl%s", input$rdo_cyl)
  })

  data0 <- reactive({
    reactiveData[[ref0()]]
  })

  ref1 <- reactive({
    sprintf("am1_cyl%s", input$rdo_cyl)
  })

  data1 <- reactive({
    reactiveData[[ref1()]]
  })

  # Event Observers -------------------------------------------------

  observeEvent(
    input$btn_save_automatic, 
    {
      in_field <- names(input)[grepl("^txt_comment_", names(input))]
      in_field_id <- sub("^txt_comment_", "", in_field)
      in_field_id <- as.numeric(in_field_id)
      in_field_id <- in_field_id[in_field_id %in% data0()$row_id]

      exist_frame <- data0()[c("row_id", "comment")]
      new_frame <- 
        data.frame(
          row_id = in_field_id, 
          comment = vapply(in_field_id, 
                           function(id){ input[[sprintf("txt_comment_%s", id)]]}, 
                           character(1)), 
          stringsAsFactors = FALSE)

      Compare <- left_join(exist_frame, 
                           new_frame, 
                           by = "row_id", 
                           suffix = c("_exist", "_new")) %>% 
        filter(comment_exist != comment_new)

      message(sprintf("* %s comment(s) saved", nrow(Compare)))

      # Only perform the save operations if there are changes to be made.
      if (nrow(Compare)){
        session$sendCustomMessage("unbind-DT", "am0")

        for(i in seq_len(nrow(Compare))){
          row <- Compare$row_id
          reactiveData[[ref0()]]$comment[reactiveData[[ref0()]]$row_id == row] <- 
            input[[sprintf("txt_comment_%s", row)]]
        }
        print(data0())
      }

    }
  )

  # Very similar to btn_save_automatic
  observeEvent(
    input$btn_save_manual, 
    {
      in_field <- names(input)[grepl("^txt_comment_", names(input))]
      in_field_id <- sub("^txt_comment_", "", in_field)
      in_field_id <- as.numeric(in_field_id)
      in_field_id <- in_field_id[in_field_id %in% data1()$row_id]

      exist_frame <- data1()[c("row_id", "comment")]
      new_frame <- 
        data.frame(
          row_id = in_field_id, 
          comment = vapply(in_field_id, 
                           function(id){ input[[sprintf("txt_comment_%s", id)]]}, 
                           character(1)), 
          stringsAsFactors = FALSE)

      Compare <- left_join(exist_frame, 
                           new_frame, 
                           by = "row_id", 
                           suffix = c("_exist", "_new")) %>% 
        filter(comment_exist != comment_new)

      message(sprintf("* %s comment(s) saved", nrow(Compare)))

      # Only perform the save operations if there are changes to be made.
      if (nrow(Compare)){
        session$sendCustomMessage("unbind-DT", "am1")

        for(i in seq_len(nrow(Compare))){
          row <- Compare$row_id
          reactiveData[[ref1()]]$comment[reactiveData[[ref1()]]$row_id == row] <- 
            input[[sprintf("txt_comment_%s", row)]]
        }
        print(data1())
      }

    }
  )


  # Output Objects --------------------------------------------------

  output$am0 <-
    DT::renderDataTable({
      # isolate: render table only once!
      make_inputtable(isolate(data0())) %>%
        datatable(escape = -13, 
                  options = list(preDrawCallback = DT::JS('function() { Shiny.unbindAll(this.api().table().node()); }'),
                                 drawCallback = DT::JS('function() { Shiny.bindAll(this.api().table().node()); } ')))
    }, server = TRUE)

  output$am1 <-
    DT::renderDataTable({
      # isolate: render table only once!
      make_inputtable(isolate(data1())) %>%
        datatable(escape = -13, 
                  options = list(preDrawCallback = DT::JS('function() { Shiny.unbindAll(this.api().table().node()); }'),
                                 drawCallback = DT::JS('function() { Shiny.bindAll(this.api().table().node()); } ')))
    }, server = TRUE)

  am0Proxy <- dataTableProxy("am0")
  am1Proxy <- dataTableProxy("am1")

  observeEvent(data0(), {
    replaceData(am0Proxy, make_inputtable(data0()), resetPaging = FALSE)  # important
  }, ignoreInit = TRUE)

  observeEvent(data1(), {
    replaceData(am1Proxy, make_inputtable(data1()), resetPaging = FALSE)  # important
  }, ignoreInit = TRUE)

})

shinyApp(ui = ui, server = server)
ismirsehregal
  • 30,045
  • 5
  • 31
  • 78
0

You are either unbinding too soon or too late, I am not certain from the code snippet you posted. Can you make multiple objects of the same type to bind to instead?

Edit:

I find this line suspicious:

# unbind a datatable. Needs to be done before a table is redrawn.
 tags$script(HTML(
   "Shiny.addCustomMessageHandler('unbind-DT', function(id) {


    Shiny.unbindAll($('#'+id).find('table').DataTable().table().node());
           })"))   )

Seems like you are unbinding twice and binding only once.

nicola
  • 24,005
  • 3
  • 35
  • 56
jo1storm
  • 125
  • 9
  • To make sure I understand, are you recommending creating six distinct output objects (instead of the two in the example) to bind against? It's possible. Again, refactoring time would be an enormous complicating factor. In practice, this would be result in 54 distinct objects for binding. (When I started the application, these comments weren't in the road map, so I'd be doing a significant refactoring to do this) – Benjamin Feb 10 '20 at 13:00
  • Yeah, that would suck. Can I learn more about the process here? Basically, as far as I can see, you are unbinding things twice and binding them once. I'll edit the answer. And hope like hell somebody more experienced comes look at this question :) – jo1storm Feb 10 '20 at 13:11
  • What I thought was happening was 1. Data from the database are loaded, and the tables are rendered. 2. User enters comments in the input boxes and presses "Save Comments" 3. Comments are saved to the database. 4. The datatables and the input boxes are unbound. 5. The data frames in `reactiveData` are updated. This triggers the datatable objects to be redrawn. I had expected on the redraw that the input fields would be rebound by the `drawCallback` argument in the `options` to `datatable`. But I see what you mean by unbinding twice between `preDrawCallback` and `unbind-DT`. – Benjamin Feb 10 '20 at 13:23
  • I see. Reload/redraw and rebind are different commands. Mayhaps you need to remove one unbind to prevent it unbinding twice and call reload function for dt object after rebinding it. – jo1storm Feb 10 '20 at 14:48