3

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% my_choices[[input$group_select[[1]]]])]
    }else{
      sel <- union(input[[names(input$group_select)]],my_choices[[input$group_select[[1]]]])
    }
    updatePickerInput(session,names(input$group_select),selected = sel)
  })
  

king_of_limes
  • 359
  • 1
  • 11

1 Answers1

2

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.

thothal
  • 16,690
  • 3
  • 36
  • 71
  • Why not simply do `$(body).on("click", ".dropdown-header", clickHandler)`, instead of using a mutation observer? – Stéphane Laurent Aug 30 '22 at 11:51
  • [Look to the original answer](https://stackoverflow.com/questions/67908249/is-there-a-way-to-select-an-entire-group-of-choices-on-a-pickerinput-from-shinyw). – thothal Aug 30 '22 at 11:52
  • Thank you very much, the JS changes do the trick. One question: If I want to customize the `updatePickerInput`, how do I access the id inside the `observeEvent`? With `input$group_select` I seem to only get the group header name. – king_of_limes Aug 30 '22 at 13:03
  • `input$group_select` does return a list, whose name equals the `pickerInput` id and the value corresponds to the group header name. Thus, `names(input$group_select)` will give you the id, and `input$group_select` will give you the group header name. – thothal Aug 30 '22 at 13:45
  • Thanks, that really helped me out a lot. It works now, the only quirk is that I cannot click on the same group header twice in a row, I need to click on another one before choosing the same one again (so e.g. no quick unselect after a misclick select). – king_of_limes Aug 30 '22 at 15:37
  • 1
    Well, change to `Shiny.setInputValue('group_select', res, {priority: 'event'})`. Then each click will be reported to shiny and you can react accordingly. Will add an update. – thothal Aug 31 '22 at 07:15