2

I have a shiny app with multiple tabs in which I am rendering rhandsontable and would like to provide a search capability. This is the module I have written for rendering such a table with search :

# Module for rendering rhandsontable with search
rtable_UI <- function(id) {
  ns <- NS(id)
  tagList(
    textInput("searchField", "Search"),
    rhandsontable::rHandsontableOutput(ns('table_output'))
  )
}

rtableServer <- function(id, df) {
  moduleServer(id, function(input, output, session) {
    output$table_output <- rhandsontable::renderRHandsontable({
      rhandsontable::rhandsontable(df, search = TRUE)
    })
  })
}

Note that searchField is not in its namespace. If I try ns("searchField" ) the search functionality does not work in my shiny app:

# Shiny App
ui <- navbarPage(
  "example",
  tabPanel(
    'First Tab',
    rtable_UI('table1')
  ),
  tabPanel(
    'Second Tab',
    rtable_UI('table2')
  )
)

server <- function(input, output, session){
  rtableServer('table1', iris)
  rtableServer('table2', mtcars)
}

shinyApp(ui, server)

The search functionality only works for the first tab and I think that’s because the ids of the search fields are the same. However changing the id also doesn’t seem to be an option as can be seen here. Is there some way of making the rhandsontable aware of the namespace?

Dhiraj
  • 1,650
  • 1
  • 18
  • 44
  • I fear that this is only possible with a change in `rhandsontalbe` itself as you've correctly noted. – starja Jun 21 '22 at 17:53
  • 2
    I could write the JS to make a search functionality that works with the tables if that's something you would be interested in using. You would call it with your app using `htmlwidgets::JS()`. If you would like that type of answer, you'll have to let me know if you want one search bar per table, one search bar per column...and anything else you were specifically interested in. Do you want it to search each time each key is pressed or after 'go' (like a button is pressed)? – Kat Jun 23 '22 at 23:50
  • 1
    @Kat, yes that would be great. I am looking at one search bar ber table that could search in multiple columns of that table and highlight the text which is entered in the search bar wherever it occurs in the table – Dhiraj Jun 24 '22 at 05:34

1 Answers1

3

This was a rather painful exercise! RHandsontable really really wanted to be in charge of the search... I digress.

If you added more tables, the only thing you would need to add is where I wrote tbr[1].classList.add('active'); /* so the data is avail */. Upon loading only the first tab is loaded. You need all the tabs to be loaded for the code to work. This only has to happen one time, so if there were three tables, for example, you would need to add tbr[2].classList.add('active');.

I didn't use any string safeguards (setting it to lowercase, trimming whitespace, etc.).

I changed the id for the search input box to searcher here so that it is unrecognized for the rhandsontable package. This has to match the id used in the JS, so if you change this string here, it has to change there, as well.

rtable_UI <- function(id) {
  ns <- NS(id)
  tagList(
    textInput(ns("searcher"), "Search"),
    rhandsontable::rHandsontableOutput(ns('table_output'))
  )
}

This is unchanged.

rtableServer <- function(id, df) {
  moduleServer(id, function(input, output, session) {
    output$table_output <- rhandsontable::renderRHandsontable({
      rhandsontable::rhandsontable(df, search = T)
    })
  })
} 

For the ui, I added tagList, tags$head, one style tag, and two scripts. One script is the changes needed to the Shiny rendering; the other script is the search function. Some of what is coded here could definitely have been done with the tag functions between shiny and htmltools, but this was a bit easier for me.

ui <- tagList(
  tags$head(
    tags$style(HTML(
      ".htSearchResult {background-color: rgb(252, 237, 217);}")),
    tags$script(type="text/javascript", HTML("setTimeout(function(){
      var tbr = document.querySelectorAll('.tab-pane');
      tbr[1].classList.add('active'); /* so the data is avail */
      var inStr = document.querySelectorAll('input');        /*the input boxes*/
      var widg = document.querySelectorAll('.html-widget');  /*the tables boxes*/
      for(i = 0; i < widg.length; i++) {
        var wId = widg[i].getAttribute('id');                /* collect table IDs */
        inStr[i].className = '';             /* disassociate rhandstable search */
        inStr[i].style.cssText = 'cursor: pointer; font-size: 1em; line-height: 1.5em; padding: 6px 12px; display: block; width: 70%;';
          /* collect label and input box; add event without rhandsontable interference */
        var par = inStr[i].parentElement    
        var ipar = par.children[0].outerHTML;
        var str = inStr[i].outerHTML;
        var html = '\"replacer(\\'' + wId + '\\')\"';
        str = str.replace('>', ' onkeyup=' + html + '>');
        par.innerHTML = ipar + str;
      }
      }, 100)")),
      tags$script(type="text/javascript", HTML("function replacer(tbl) {
        $('#' + tbl).first().find('.htSearchResult').removeClass('htSearchResult'); /*remove previous search highlight*/
        var searchword = $('#' + tbl.substring(0,6) + '-searcher').val();       /* collect input */
        var custfilter = new RegExp(searchword, 'ig');                 /* setup input for search */
        if (searchword !== '') {
          looker = document.querySelector('#' + tbl);
          tellMe = looker.querySelectorAll('td');     /*look at all table cells of specific table*/
          for(i = 0; i < tellMe.length; i++) {
            tm = tellMe[i].innerText.toString();    
            if(tm.includes(searchword)){
              console.log(tellMe[i]);
              tellMe[i].classList.add('htSearchResult');    /*highlight partial match table cells*/
            }
          }
        }
      }"))),
  navbarPage(
    "example",
    tabPanel(
      'First Tab',
      rtable_UI('table1')
    ),
    tabPanel(
      'Second Tab',
      rtable_UI('table2')
    )
  ))

The call for server was not changed.

server <- function(input, output, session){
  rtableServer('table1', iris)
  rtableServer('table2', mtcars)
}

shinyApp(ui, server)

enter image description here

enter image description here

Updated for Scrolling

Alright—this update is about scrolling and ensuring that the text is highlighted.

I know that what you're working with now doesn't mirror what's in this answer for a variety of reasons. First, I'll outline in words what I've done, then code (based on my original answer).

I essentially split the function replacer. For scrolling, you don't want to remove the highlight. So there's a function with all of the code (called addMore) that has everything except the first line of replacer. The replacer function has just the original first line, then calls the function addMore.

When you add the functions to create the search bars, the tables are not available. Therefore there is nothing to attach an onscroll event to. So I created a keydown event, so that the first time a table is searched, it removes the keydown event from the input box and adds a scroll event to the right element (widget's first child's first child). You may want to use a different event than keydown, because whatever you type first is not in the input box. (That could be annoying!)

First the addMore function

tags$script(type="text/javascript", HTML("
      function addMore(tbl) {
        var searchword = $('#' + tbl.substring(0,6) + '-searcher').val(); /* collect input */
        var custfilter = new RegExp(searchword, 'ig'); /* setup input for search */
        if (searchword !== '') {
          looker = document.querySelector('#' + tbl);
          tellMe = looker.querySelectorAll('td');   /*look at all table cells of specific table*/
          for(i = 0; i < tellMe.length; i++) {
            tm = tellMe[i].innerText.toString();    
            if(tm.includes(searchword)){
              console.log(tellMe[i]);
              tellMe[i].classList.add('htSearchResult');    /*highlight partial match table cells*/
            }
          }
        }
      }"))

The new replacer function.

  tags$script(type="text/javascript", HTML("function replacer(tbl) {
        $('#' + tbl).first().find('.htSearchResult').removeClass('htSearchResult'); /*remove previous search highlight*/
        addMore(tbl);
      }"))

This function is what deletes the keydown event and adds the onscroll event.

tags$script(type="text/javascript", HTML("function popOnce(inid, widd){
  win = document.querySelector('input#' + inid);                                    
  /*wait for the first attempt to search to add listeners for scroll*/
  var par = win.parentElement    
  var ipar = par.children[0].outerHTML;
  var str = win.outerHTML;
  rx = /onkeydown.*\"/g;
  str = str.replace(rx, '');
  par.innerHTML = ipar + str;
  widg = document.querySelector('div#' + widd);
  where = widg.querySelector('div.ht_master > div.wtHolder');
  where.addEventListener('scroll', function(){addMore(widd)});
}")),

Finally, the setTimeout function. In this function, I've added the onkeydown at the same time I added the onkeyup.

tags$script(type="text/javascript", HTML("setTimeout(function(){
  var tbr = document.querySelectorAll('.tab-pane');
  tbr[1].classList.add('active'); /* so the data is avail */
  var inStr = document.querySelectorAll('input[id*=\"searcher\"]');        /*the input boxes*/
  console.log(inStr[0].getAttribute('id'));
  var widg = document.querySelectorAll('.html-widget');  /*the tables boxes*/
  for(i = 0; i < widg.length; i++) {
    var wId = widg[i].getAttribute('id');                /* collect table IDs */
    var insid = inStr[i].getAttribute('id');
    inStr[i].className = '';             /* disassociate rhandstable searcher */
    inStr[i].style.cssText = 'cursor: pointer; font-size: 1em; line-height: 1.5em; padding: 6px 12px; display: block; width: 70%;';
      /* collect label and input box; add event without rhandsontable interference */
    var par = inStr[i].parentElement    
    var ipar = par.children[0].outerHTML;
    var str = inStr[i].outerHTML;
    var html = '\"replacer(\\'' + wId + '\\')\"';
    var html2 = '\"popOnce(\\'' + insid + '\\',\\'' + wId + '\\')\"';
    /*str = str.replace('>', ' onkeyup=' + html + '>'); original before highlight scrolling */
    str = str.replace('>', ' onkeyup=' + html + ' onkeydown=' + html2 + '>');
    par.innerHTML = ipar + str;
  }}, 400)")) 

This is the same code as above, but it's all together as one chunk of code.

rtable_UI <- function(id) {
  ns <- NS(id)
  tagList(
    textInput(ns("searcher"), "Search"),
    rhandsontable::rHandsontableOutput(ns('table_output'))
  )
}

rtableServer <- function(id, df) {
  moduleServer(id, function(input, output, session) {
    output$table_output <- rhandsontable::renderRHandsontable({
      rhandsontable::rhandsontable(df, search = T, height = 700)
    })
  })
}

# Shiny App
ui <- tagList(
  tags$head(
    tags$style(HTML("
      .htSearchResult {
        background-color: rgb(252, 237, 217);
      }
      .rhandsontable.html-widget {
        overflow: hidden;
      }")),
    # next function was replacer—without the removing the highlighting
    tags$script(type="text/javascript", HTML("
      function addMore(tbl) {     /* collect input */
        var searchword = $('#' + tbl.substring(0,6) + '-searcher').val();
        var custfilter = new RegExp(searchword, 'ig'); /* setup input for search */
        if (searchword !== '') {
          looker = document.querySelector('#' + tbl);
          tellMe = looker.querySelectorAll('td');     /*look at all table cells of specific table*/
          for(i = 0; i < tellMe.length; i++) {
            tm = tellMe[i].innerText.toString();    
            if(tm.includes(searchword)){
              console.log(tellMe[i]);
              tellMe[i].classList.add('htSearchResult');  /*highlight partial match table cells*/
            }
          }
        }
      }")),
    tags$script(type="text/javascript", HTML("function popOnce(inid, widd){
      win = document.querySelector('input#' + inid);                                    
      /*wait for the first attempt to search to add listeners for scroll*/
      var par = win.parentElement    
      var ipar = par.children[0].outerHTML;
      var str = win.outerHTML;
      rx = /onkeydown.*\"/g;
      str = str.replace(rx, '');
      par.innerHTML = ipar + str;
      widg = document.querySelector('div#' + widd);
      where = widg.querySelector('div.ht_master > div.wtHolder');
      where.addEventListener('scroll', function(){addMore(widd)});
    }")),
    tags$script(type="text/javascript", HTML("setTimeout(function(){
      var tbr = document.querySelectorAll('.tab-pane');
      tbr[1].classList.add('active'); /* so the data is avail */
      var inStr = document.querySelectorAll('input[id*=\"searcher\"]');   /*the input boxes*/
      console.log(inStr[0].getAttribute('id'));
      var widg = document.querySelectorAll('.html-widget');  /*the tables boxes*/
      for(i = 0; i < widg.length; i++) {
        var wId = widg[i].getAttribute('id');                /* collect table IDs */
        var insid = inStr[i].getAttribute('id');
        inStr[i].className = '';             /* disassociate rhandstable searcher */
        inStr[i].style.cssText = 'cursor: pointer; font-size: 1em; line-height: 1.5em; padding: 6px 12px; display: block; width: 70%;';
          /* collect label and input box; add event without rhandsontable interference */
        var par = inStr[i].parentElement    
        var ipar = par.children[0].outerHTML;
        var str = inStr[i].outerHTML;
        var html = '\"replacer(\\'' + wId + '\\')\"';
        var html2 = '\"popOnce(\\'' + insid + '\\',\\'' + wId + '\\')\"';
        /* highlight scrolling modification */
        str = str.replace('>', ' onkeyup=' + html + ' onkeydown=' + html2 + '>');
        par.innerHTML = ipar + str;
      }}, 400)")),
  tags$script(type="text/javascript", HTML("function replacer(tbl) {
        $('#' + tbl).first().find('.htSearchResult').removeClass('htSearchResult'); /*remove previous search highlight*/
        addMore(tbl);
      }"))),
  navbarPage(
    "example",
    tabPanel(
      'First Tab',
      rtable_UI('table1')
    ),
    tabPanel(
      'Second Tab',
      rtable_UI('table2')
    )
  ))

server <- function(input, output, session){
  rtableServer('table1', iris)
  rtableServer('table2', mtcars)
}

shinyApp(ui, server)

enter image description here

Kat
  • 15,669
  • 3
  • 18
  • 51
  • thank you so much for doing this! I have a large golem based shiny app and will try and incorporate your solution wherever the app uses rhandsontable. Much appreciated! – Dhiraj Jun 26 '22 at 06:45
  • in my golem based app, the number of tabs visible changes based on the login user group. Hence the number of tables visible also changes. So I guess I can't hardcode the index for tbr, can I? – Dhiraj Jun 26 '22 at 14:24
  • With only 1 or 2, static changes made sense. However, if you could've 1 to many, then make it dynamic. Instead of `tbr[1].classList.add('active');` make it a loop. `for(i = 0; i < tbr.length; i++){tbr[i].classList.add('active');}` – Kat Jun 26 '22 at 16:57
  • great that works, thanks. Also after a lot of debugging, I realized that searchword variable is actually dependent on the module id...has to be 6 characters long. Any way to make it more generic? – Dhiraj Jun 27 '22 at 11:12
  • This is what's connecting the right search bar with the right table. In `server`, you've named the outputs `table1` and `table2`. In `ui`, you've got `table_output` as the id. This equates to having an element id that is `table1-table_output` and `table2-table_output` (`table1` is 6 characters). Your search boxes get `table1` or `table2`, but with `-searcher`. So I've taken the first 6 characters of the table id (passed to the function as `tbl`) to make sure the right table is going with the right search bar. This can be done differently; did you have something specific in mind? – Kat Jun 27 '22 at 18:37
  • 1
    To make it more generic, as this table could be part of another module itself, (could have an id like `main_1-table_1-table_output`), I modified your searchword: `var searchword = $('#' + tbl.substring(0, tbl.lastIndexOf('-')) + '-searcher').val(); `. This is working now irrespective of the module id. – Dhiraj Jun 28 '22 at 06:30
  • if the tab has any other `input` widget, (textInput), this functionality is not working. Do you reckon it is something to do with the `var inStr` variable? – Dhiraj Jun 30 '22 at 13:50
  • Yes; you could add more qualifiers to that query. For example, `var inStr = document.querySelectorAll('input[id*=\"searcher\"]');` Let me know if that doesn't help/work. – Kat Jun 30 '22 at 14:35
  • yes that works thanks. Unfortunately I have some rhandsontables that will render only on an action button click. So the `setTimeout()` will not work. Any way of overcoming this? – Dhiraj Jul 06 '22 at 03:21
  • Figured. Used `shinyjs::extendShinyjs()` to call a function that executes the code you wrote in the timeout function – Dhiraj Jul 06 '22 at 11:08
  • So you're good, right? If not, you'll have to give me more details on what's happening with the others. – Kat Jul 06 '22 at 16:16
  • Yes, for most part. But the search functionality doesn't highlight the cells if it is a long table and scrolling is required. Not sure how to fix that. – Dhiraj Jul 08 '22 at 04:34
  • Do you want to scroll? Or would you rather have the entire table fit (and the content below moves down, as well)? I don't know if it would work out, since I don't have all of your code, but you could append the ElementQueries.js and ResizeSensor.js to the head. If you want to try it I can add the JS to do this to my answer. You can't have the CSS 'resize' set to the tables, though. – Kat Jul 08 '22 at 14:44
  • Since some of the tables are really long, I render the tables with a max height of 700px. Would be great if you could add the JS to your answer. – Dhiraj Jul 09 '22 at 00:28
  • 1
    Updated my answer based on your desire to keep the tables a specific size. – Kat Jul 09 '22 at 17:07