21

I would like to have a shiny website that keeps the dynamic choices in the URL as output, so you can copy and share the URL. I took this code as an example: https://gist.github.com/amackey/6841cf03e54d021175f0

And modified it to my case, which is a webpage with a navbarPage and multiple tabs per element in the bar.

What I would like is the URL to direct the user to the right element in the first level tabPanel, and the right tab in the second level tabPanel.

This is, if the user has navigated to "Delta Foxtrot" and then to "Hotel", then changed the parameters to #beverage=Tea;milk=TRUE;sugarLumps=3;customer=mycustomer, I would like the URL to send the user to "Delta Foxtrot" -> "Hotel", instead of starting at the first tab of the first panel element.

Ideally I would like a working example, since everything I tried so far hasn't worked.

Any ideas?

enter image description here

# ui.R
library(shiny)

hashProxy <- function(inputoutputID) {
  div(id=inputoutputID,class=inputoutputID,tag("div",""));
}

# Define UI for shiny d3 chatter application
shinyUI(navbarPage('URLtests', id="page", collapsable=TRUE, inverse=FALSE,
 tabPanel("Alfa Bravo",
   tabsetPanel(
    tabPanel("Charlie",
    tags$p("Nothing to see here. Everything is in the 'Delta Foxtrot' 'Hotel' tab")
             )
       )
    )
 ,tabPanel("Delta Foxtrot",
    tabsetPanel(
    tabPanel("Golf",
    tags$p("Nothing to see here. Everything is in the 'Delta Foxtrot' 'Hotel' tab")
             )
    ,tabPanel("Hotel",

    tags$p("This widget is a demonstration of how to preserve input state across sessions, using the URL hash."),
    selectInput("beverage", "Choose a beverage:",
                choices = c("Tea", "Coffee", "Cocoa")),
    checkboxInput("milk", "Milk"),
    sliderInput("sugarLumps", "Sugar Lumps:",
                min=0, max=10, value=3),
    textInput("customer", "Your Name:"),
    includeHTML("URL.js"),
    h3(textOutput("order")),
    hashProxy("hash")
       )
     )
   )
))


# server.R
library(shiny)
url_fields_to_sync <- c("beverage","milk","sugarLumps","customer");

# Define server logic required to respond to d3 requests
shinyServer(function(input, output, clientData) {

  # Generate a plot of the requested variable against mpg and only
  # include outliers if requested
  output$order <- reactiveText(function() {
    paste(input$beverage,
          if(input$milk) "with milk" else ", black",
          "and",
          if (input$sugarLumps == 0) "no" else input$sugarLumps,
          "sugar lumps",
          "for",
          if (input$customer == "") "next customer" else input$customer)
  })

  firstTime <- TRUE

  output$hash <- reactiveText(function() {

    newHash = paste(collapse=";",
                    Map(function(field) {
                          paste(sep="=",
                                field,
                                input[[field]])
                        },
                        url_fields_to_sync))

    # the VERY FIRST time we pass the input hash up.
    return(
      if (!firstTime) {
        newHash
      } else {
        if (is.null(input$hash)) {
          NULL
        } else {
          firstTime<<-F;
          isolate(input$hash)
        }
      }
    )
  })
})


# URL.js
<script type="text/javascript">
(function(){

  this.countValue=0;

  var changeInputsFromHash = function(newHash) {
    // get hash OUTPUT
    var hashVal = $(newHash).data().shinyInputBinding.getValue($(newHash))
    if (hashVal == "") return
    // get values encoded in hash
    var keyVals = hashVal.substring(1).split(";").map(function(x){return x.split("=")})
    // find input bindings corresponding to them
    keyVals.map(function(x) {
      var el=$("#"+x[0])

      if (el.length > 0 && el.val() != x[1]) {

        console.log("Attempting to update input " + x[0] + " with value " + x[1]);
        if (el.attr("type") == "checkbox") {
            el.prop('checked',x[1]=="TRUE")
            el.change()
        } else if(el.attr("type") == "radio") {
          console.log("I don't know how to update radios")
        } else if(el.attr("type") == "slider") {
          // This case should be setValue but it's not implemented in shiny
          el.slider("value",x[1])
          //el.change()
        } else { 
            el.data().shinyInputBinding.setValue(el[0],x[1])
            el.change()
        }
      }
    })
  }

  var HashOutputBinding = new Shiny.OutputBinding();
  $.extend(HashOutputBinding, {
    find: function(scope) {
      return $(scope).find(".hash");
    },
    renderError: function(el,error) {
      console.log("Shiny app failed to calculate new hash");
    },
    renderValue: function(el,data) {
      console.log("Updated hash");
      document.location.hash=data;
      changeInputsFromHash(el);
    }
  });
  Shiny.outputBindings.register(HashOutputBinding);

  var HashInputBinding = new Shiny.InputBinding();
  $.extend(HashInputBinding, {
    find: function(scope) {
      return $(scope).find(".hash");
    },
    getValue: function(el) {
      return document.location.hash;
    },
    subscribe: function(el, callback) {
      window.addEventListener("hashchange",
        function(e) {
          changeInputsFromHash(el);
          callback();
        }
        , false);
    }
  });
  Shiny.inputBindings.register(HashInputBinding);


})()
</script>

EDITED: I ran the example code in the answer, but couldn't get it to work. See screenshot.

enter image description here

719016
  • 9,922
  • 20
  • 85
  • 158

2 Answers2

18

UPDATE

Shiny .14 now available on CRAN supports saving app state in a URL. See this article


This answer is a more in-depth answer than my first that uses the entire sample code provided by OP. I've decided to add it as a new answer in light of the bounty. My original answer used a simplified version of this so that someone else coming to the answer wouldn't have to read through any extraneous code to find what they're looking for. Hopefully, this extended version will clear up any difficulties you're having. Parts I've added to your R code are surrounded with ### ... ###.

server.r

# server.R
library(shiny)
url_fields_to_sync <- c("beverage","milk","sugarLumps","customer");

# Define server logic required to respond to d3 requests
shinyServer(function(input, output, session) { # session is the common name for this variable, not clientData

  # Generate a plot of the requested variable against mpg and only
  # include outliers if requested
  output$order <- reactiveText(function() {
    paste(input$beverage,
          if(input$milk) "with milk" else ", black",
          "and",
          if (input$sugarLumps == 0) "no" else input$sugarLumps,
          "sugar lumps",
          "for",
          if (input$customer == "") "next customer" else input$customer)
  })

  firstTime <- TRUE

  output$hash <- reactiveText(function() {

    newHash = paste(collapse=";",
                    Map(function(field) {
                          paste(sep="=",
                                field,
                                input[[field]])
                        },
                        url_fields_to_sync))

    # the VERY FIRST time we pass the input hash up.
    return(
      if (!firstTime) {
        newHash
      } else {
        if (is.null(input$hash)) {
          NULL
        } else {
          firstTime<<-F;
          isolate(input$hash)
        }
      }
    )
  })

  ###

  # whenever your input values change, including the navbar and tabpanels, send
  # a message to the client to update the URL with the input variables.
  # setURL is defined in url_handler.js
  observe({
      reactlist <- reactiveValuesToList(input)
      reactvals <- grep("^ss-|^shiny-", names(reactlist), value=TRUE, invert=TRUE) # strip shiny related URL parameters
      reactstr <- lapply(reactlist[reactvals], as.character) # handle conversion of special data types
      session$sendCustomMessage(type='setURL', reactstr)
  })

  observe({ # this observer executes once, when the page loads

      # data is a list when an entry for each variable specified 
      # in the URL. We'll assume the possibility of the following 
      # variables, which may or may not be present:
      #   nav= The navbar tab desired (either Alfa Bravo or Delta Foxtrot)
      #   tab= The desired tab within the specified nav bar tab, e.g., Golf or Hotel
      #   beverage= The desired beverage selection
      #   sugar= The desired number of sugar lumps
      # 
      # If any of these variables aren't specified, they won't be used, and 
      # the tabs and inputs will remain at their default value.
      data <- parseQueryString(session$clientData$url_search)
      # the navbar tab and tabpanel variables are two variables 
      # we have to pass to the client for the update to take place
      # if nav is defined, send a message to the client to set the nav tab
      if (! is.null(data$page)) {
          session$sendCustomMessage(type='setNavbar', data)
      }

      # if the tab variable is defined, send a message to client to update the tab
      if (any(sapply(data[c('alfa_bravo_tabs', 'delta_foxtrot_tabs')], Negate(is.null)))) {
          session$sendCustomMessage(type='setTab', data)
      }

      # the rest of the variables can be set with shiny's update* methods
      if (! is.null(data$beverage)) { # if a variable isn't specified, it will be NULL
          updateSelectInput(session, 'beverage', selected=data$beverage)
      }

      if (! is.null(data$sugarLumps)) {
          sugar <- as.numeric(data$sugarLumps) # variables come in as character, update to numeric
          updateNumericInput(session, 'sugarLumps', value=sugar)
      }
  })

  ###
})

ui.r

library(shiny)

hashProxy <- function(inputoutputID) {
  div(id=inputoutputID,class=inputoutputID,tag("div",""));
}

# Define UI for shiny d3 chatter application
shinyUI(navbarPage('URLtests', id="page", collapsable=TRUE, inverse=FALSE,
 tabPanel("Alfa Bravo",
   tabsetPanel(
    ###
    id='alfa_bravo_tabs', # you need to set an ID for your tabpanels
    ###
    tabPanel("Charlie",
    tags$p("Nothing to see here. Everything is in the 'Delta Foxtrot' 'Hotel' tab")
             )
       )
    )
 ,tabPanel("Delta Foxtrot",
    tabsetPanel(
    ###
    id='delta_foxtrot_tabs', # you need to set an ID for your tabpanels
    ###
    tabPanel("Golf",
    tags$p("Nothing to see here. Everything is in the 'Delta Foxtrot' 'Hotel' tab")
             )
    ,tabPanel("Hotel", id='hotel',

    tags$p("This widget is a demonstration of how to preserve input state across sessions, using the URL hash."),
    selectInput("beverage", "Choose a beverage:",
                choices = c("Tea", "Coffee", "Cocoa")),
    checkboxInput("milk", "Milk"),
    sliderInput("sugarLumps", "Sugar Lumps:",
                min=0, max=10, value=3),
    textInput("customer", "Your Name:"),
    #includeHTML("URL.js"),
    ###
    includeHTML('url_handler.js'), # include the new script
    ###
    h3(textOutput("order")),
    hashProxy("hash")
       )
     )
   )
))

url_handler.js

<script>
Shiny.addCustomMessageHandler('setNavbar',
    function(data) {
        // create a reference to the desired navbar tab. page is the 
        // id of the navbarPage. a:contains says look for 
        // the subelement that contains the contents of data.nav
        var nav_ref = '#page a:contains(\"' + data.page + '\")';
        $(nav_ref).tab('show');
    }
)

Shiny.addCustomMessageHandler('setTab',
    function(data) {
       // pick the right tabpanel ID based on the value of data.nav
       if (data.page == 'Alfa Bravo') {
            var tabpanel_id = 'alfa_bravo_tabs';
       } else {
            var tabpanel_id = 'delta_foxtrot_tabs';
       }
       // combine this with a reference to the desired tab itself.
       var tab_ref = '#' + tabpanel_id + ' a:contains(\"' + data[tabpanel_id] + '\")';
       $(tab_ref).tab('show');
    }
)

Shiny.addCustomMessageHandler('setURL',
    function(data) {
        // make each key and value URL safe (replacing spaces, etc.), then join
        // them and put them in the URL
        var search_terms = [];
        for (var key in data) {
            search_terms.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
        }
        window.history.pushState('object or string', 'Title', '/?' + search_terms.join('&'));
    }
);

</script>

To test this, call runApp(port=5678) in the directory with your source files. By default, no parameters are specified in the URL, so this will default to the first navbar item and the first tab within that item. To test it with URL parameters, point your browser to: http://127.0.0.1:5678/?nav=Delta%20Foxtrot&tab=Hotel&beverage=Coffee. This should point you to the second navbar tab and the second tab in that navbar item with coffee as the selected beverage.

Matthew Plourde
  • 43,932
  • 7
  • 96
  • 113
  • This now works! thanks for the answer. The only issue is that navigating through the `nav` and `tab` alone does not update the url. So as you say, one needs to start with a specified `nav` and `tab`, otherwise, it doesn't load the value to the url, as it does for the rest of the options... – 719016 Aug 20 '14 at 09:59
  • Yes, updating the current URL with variables would be a separate question. You should ask it! – Matthew Plourde Aug 20 '14 at 14:27
  • 1
    @200508519211022689616937 updated the answer so that changing input values now automatically updates the URL to reflect the current state. – Matthew Plourde Aug 21 '14 at 00:29
  • It now works wonderfully well. There are some extra tags in there, like `ss-net-opt-xdr-streaming=true`, not sure if they are needed. thanks for the answer :-) – 719016 Aug 21 '14 at 19:17
  • 1
    No prob! If you want to remove parameters so they won't show up in the URL, you could replace `reactiveValuesToList(input)` with something like `reactiveValuesToList(input[setdiff(names(input), 'ss-net-opt-xdr-streaming')])` – Matthew Plourde Aug 21 '14 at 19:23
  • @MatthewPlourde this is working now but I have it in a server called `https://abc.domain.com/websiteA/` and the current code gobbles up the `/websiteA/` part giving me something like `https://abc.domain.com/?sugarLumps=3` instead of `https://abc.domain.com/websiteA/?sugarLumps=3`. Any solutions? – 719016 Sep 09 '14 at 08:47
4

Here's an example demonstrating how to update the navbar selection, tabset selection, and widget selection using variables defined in the URL

ui <- navbarPage('TEST', id='page', collapsable=TRUE, inverse=FALSE,
    # define a message handler that will receive the variables on the client side
    # from the server and update the page accordingly.
    tags$head(tags$script("
        Shiny.addCustomMessageHandler('updateSelections',
            function(data) {
                var nav_ref = '#page a:contains(\"' + data.nav + '\")';
                var tabpanel_id = data.nav == 'Alpha' ? '#alpha_tabs' : '#beta_tabs';
                var tab_ref = tabpanel_id + ' a:contains(\"' + data.tab + '\")';
                $(nav_ref).tab('show');
                $(tab_ref).tab('show');
            }
        )
    ")),
    tabPanel('Alpha',
        tabsetPanel(id='alpha_tabs',
            tabPanel('Tab')
        )
    ),
    tabPanel('Beta',
        tabsetPanel(id='beta_tabs',
            tabPanel('Golf'),
            tabPanel('Hotel',
                selectInput("beverage", "Choose a beverage:", choices = c("Tea", "Coffee", "Cocoa"))
            )
        )
    )
)

server <- function(input, output, session) {
    observe({
        data <- parseQueryString(session$clientData$url_search)
        session$sendCustomMessage(type='updateSelections', data)
        updateSelectInput(session, 'beverage', selected=data$beverage)
    })

}

runApp(list(ui=ui, server=server), port=5678, launch.browser=FALSE)

Point your browser to this URL after starting the app: http://127.0.0.1:5678/?nav=Beta&tab=Hotel&beverage=Coffee


Matthew Plourde
  • 43,932
  • 7
  • 96
  • 113
  • can I call this javascript function cumulatively for every tab? – 719016 Aug 14 '14 at 20:02
  • The javascript function only gets called once, when the page loads. – Matthew Plourde Aug 14 '14 at 20:04
  • I see, so I need to list all the possible tabs in this line? ` var tabpanel_id = data.nav == 'Alpha' ? '#alpha_tabs' : '#beta_tabs'; ` – 719016 Aug 14 '14 at 20:11
  • This will work for all the nav+tab combinations present. Maybe I'm not understanding your question. – Matthew Plourde Aug 14 '14 at 20:14
  • I didn't see your comment edit. Yes, all nav tabs should be conditioned on. If you have more than two nav tabs, you'll have to use the `if...else` sytanx – Matthew Plourde Aug 14 '14 at 20:30
  • I ran the example code in the answer, but couldn't get it to work. See screenshot in edited question. – 719016 Aug 15 '14 at 12:19
  • This answer assumes that there are variables in the URL. You'll have to add checks yourself for when the URL doesn't contain variables or only some of the variables. Notice at the end of the answer I tell you which URL to use and this URL has variables in it. – Matthew Plourde Aug 15 '14 at 12:38
  • I don't seem to be able to combine what I have to what you wrote in the answer. My variables are in `input$var` whereas the tabsets are getting encoded in javascript `nav` values. It would be great to have a fully working example... – 719016 Aug 15 '14 at 13:25
  • 2
    This answer shows you the method for pulling parameters from a URL and using them to set the nav tab, tabpanel tab, and input values. You're going to have to make a little effort to adapt it to your situation. – Matthew Plourde Aug 15 '14 at 13:28
  • Oh, I now understand why it wasn't working and why you were saying the defaults need fixing: it's only if you start with a specific `nav=value` that it's pointing to the right place. So I could simply fill in `nav` to the starting tab, no sure how. It still creates a weird empty link between `TEST` and `Alfa` `Beta` with id `#tab-8292-1` – 719016 Aug 15 '14 at 14:06
  • It shouldn't create the weird empty link. It sounds like there's an error in the javascript. If you're using Chrome, press Ctrl+J to see the console, which will print any errors. – Matthew Plourde Aug 15 '14 at 14:42
  • It shows the weird empty link both on my local RStudio session and on my Shiny server. Ctrl+J shows no errors when I run it on 127.0.0.1. In my shiny server, tt shows a websocket connection error that I've seen before and no other errors. – 719016 Aug 15 '14 at 15:03
  • @Matthew Plourde. Your solution is working fine. But if we put reactive selectinput, then it is not working. – manu p Nov 15 '21 at 17:57