javascriptrshinymutation-observers

Can you tell a MutationObserver to only listen to specific inputs, e.g. to select an entire group of choices in a pickerInput?


This is inspired by and a follow up to Is there a way to select an entire group of choices on a pickerInput from shinyWidgets?

The selected answer works wonderfully for a single pickerInput, but problems arise as soon as a second ("later") one is in the same app. For example, consider a setup with two pickerInput:

library(shiny)
library(shinyWidgets)

js <- HTML("
$(function() {
  let observer = new MutationObserver(callback);

  function clickHandler(evt) {
    Shiny.setInputValue('group_select', $(this).children('span').text(),{priority: \"event\"});
  }

  function callback(mutations) {
    for (let mutation of mutations) {
      if (mutation.type === 'childList') {
        $('.dropdown-header').on('click', clickHandler).css('cursor', 'pointer');
        
      }
    }
  }

  let options = {
    childList: true,
  };

  observer.observe($('.inner')[0], options);
})
")

choices <- list("A" = c(1, 2, 3, 4, 5), "B" = c(6, 7, 8, 9, 10), "C" = c(11,12,13,14,15))

ui <- fluidPage(
    tags$head(tags$script(js)),
    pickerInput("test", choices = choices, multiple = TRUE,options = list('actions-box' = TRUE)),
    textOutput("testOutput"),
    pickerInput("test_2", choices = choices, multiple = TRUE,options = list('actions-box' = TRUE)),
    textOutput("testOutput_2")
)

server <- function(input, output, session) {
    output$testOutput <- renderText({paste(input$test)})
    output$testOutput_2 <- renderText({paste(input$test_2)})
    
    observeEvent(input$group_select, {
        req(input$group_select)
        if(all(choices[[input$group_select]] %in% input$test_2)){
            sel <- input$test_2[!(input$test_2 %in% choices[[input$group_select]])]
        }else{
            sel <- union(input$test_2, choices[[input$group_select]])
        }
        updatePickerInput(session, "test_2", selected = sel)
    }) 
}

shinyApp(ui = ui, server = server)

With this, clicking on a group in the first pickerInput updates the second one while clicking on a group in the second one does nothing. How do I tell the MutationObserver to listen for and only for mutations in the second pickerInput?

In my actual usecase, I have two different pickerInput on two different tabs (from shinydashboard) that I'd like to have this functionality for. So maybe it is enough to tell the MutationObserver on which tab to look? (See How do I use shinyjs to identify if tab is active in shiny? , but being a JS beginner, I am not really sure how I can use this).

UPDATE

I've managed to get ever so slightly closer in my usecase by adding req(input$sidebar == "tab_name") to the observeEvent parts. Now they both update the correct pickerInput, but for the second one, the functionality works only once after every time I've clicked on a group header in the first pickerInput.

So, still not there. I'm trying to get a reproducible example using tabs from shinydashboard, but for some reason as soon as I introduce them to the MWE, the whole mutationObserver stops working altogether.

UPDATE 2

@thothal's answer worked. I rewrote the observeEvent part to the following to get the same functionality:

observeEvent(input$group_select, {
    req(input$group_select)
    if(all(choices[[input$group_select[[1]]]] %in% input[[names(input$group_select)]])){
      sel <- input[[names(input$group_select)]][!(input[[names(input$group_select)]] %in% choices[[input$group_select[[1]]]])]
    }else{
      sel <- union(input[[names(input$group_select)]],choices[[input$group_select[[1]]]])
    }
    updatePickerInput(session,names(input$group_select),selected = sel)
  })
  


Solution

  • Author of the original answer here. Actually, you need to adapt the code as follows:

    1. Your clickHandler should return the id of the clicked pickerInput such that Shiny can know which pickerInput to update.
    2. Your observer must listen on a different node. The class .inner worked well with fluidPage layouts as apparently the .inner class is existing when the DOM is loaded, but not with shinydashboard (I guess that the parts are not yet loaded when the JS fires). The easiest is to listen to body (which will be there for sure), but it may be an expensive operation, b/c a narrower target may require less resources.

    Having said that, a working solution looks like this (N.B. You can add as many pickerInputs and the handler should work as expected):

    library(shiny)
    library(shinyWidgets)
    library(shinydashboard)
    library(purrr)
    
    js <- HTML("
    $(function() {
      let observer = new MutationObserver(callback);
    
      function clickHandler(evt) {
        let id = $(this).parents('.bootstrap-select').children('select').attr('id');
        let res = {};
        res[id] = $(this).children('span').text();
        Shiny.setInputValue('group_select', res);
      }
    
      function callback(mutations) {
        for (let mutation of mutations) {
          if (mutation.type === 'childList') {
            $('.dropdown-header').on('click', clickHandler).css('cursor', 'pointer');
            
          }
        }
      }
    
      let options = {
        childList: true,
      };
    
      observer.observe($('body')[0], options);
    })
    ")
    
    choices <- list("A" = c(1, 2, 3, 4, 5), "B" = c(6, 7, 8, 9, 10))
    
    ui <- dashboardPage(
       dashboardHeader(),
       dashboardSidebar(pickerInput("test1", choices = choices, multiple = TRUE),
                         pickerInput("test2", choices = choices, multiple = TRUE)),
       dashboardBody(tags$head(tags$script(js)),
                     verbatimTextOutput("testOutput")),
       title = "Multipicker"
    )
    
    server <- function(input, output, session) {
       output$testOutput <- renderPrint({
          input$group_select
       })
       
       observeEvent(input$group_select, {
          req(input$group_select)
          iwalk(input$group_select, ~ updatePickerInput(session, .y, selected = choices[[.x]]))
       })
    }
    
    shinyApp(ui = ui, server = server)
    

    Update

    In order to allow for deselecting upon second click and keeping the selection persistent, we have to make 2 changes:

    1. Tell JS to send each click (and not only new clicks):
    function clickHandler(evt) {
        let id = $(this).parents('.bootstrap-select').children('select').attr('id');
        let res = {};
        res[id] = $(this).children('span').text();
        Shiny.setInputValue('group_select', res, {priority: 'event'}); // change here
    }
    
    1. Adapt the observer
    observeEvent(input$group_select, {
       req(input$group_select)
       iwalk(input$group_select, function(.x, .y) {
          sel <- input[[.y]]
          new <- choices[[.x]]
          if (length(intersect(sel, new))) {
             ## unselect already selected items
             sel <- setdiff(sel, new)
          } else {
             sel <- union(sel, new)
          }
          updatePickerInput(session, .y, selected = sel)
          })
    })
    

    The behaviour is now that a click will add all the sub-items if (and only if) none of the sub items is already selected. If there is at least one sub-item selected all subitems are deselected. Finally, selections pile up. That is previous selections are not discarded but a new selection is added to the previous.