-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Dynamic tabs #1794
Dynamic tabs #1794
Conversation
R/insert-ui.R
Outdated
} | ||
|
||
|
||
#' Dynamically remove a tabPanel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would make sense to put the help for all of the new functions together into a single help file, using @rdname
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I struggle a bit with this. I think it definitely makes sense to link the functions around (maybe even have the same description at the top, with links to the docs of four all functions right there). So, why not put it all in a single help file, like you suggest? My thinking was that I, as an user, would much prefer to have one help file per function, since that allows me to have a usage example for that function only. Since these are related, but different, functions, seeing a usage example for each one could be beneficial (or at least that's my theory). I'm really not sure though, so I'd love to know your and @jcheng5's opinion about this, since I imagine it's common "problem" when writing documentation for family of related, but different, functions...
R/insert-ui.R
Outdated
|
||
iconClass <- tab$attribs$`data-icon-class` | ||
icon <- if (!is.null(iconClass)) { | ||
# for font-awesome we specify fixed-width |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this needed for font awesome (and not for glyphicons)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was copied from here: (R/bootstrap.R#L818-L823)[https://github.com/rstudio/shiny/blob/master/R/bootstrap.R#L818-L823]. I admit i didn't really look into it, but I can if neither you or @jcheng5 remember this. Let me know!
R/insert-ui.R
Outdated
#' | ||
#' @param immediate Whether \code{tab} should be immediately inserted into | ||
#' the app when you call \code{insertTab}, or whether Shiny should wait until | ||
#' all outputs have been updated and all observers have been run (default). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious: when would people want to run it immediately, and when would the want to wait for a flush?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This might've been inherited from insertUI
. In that case you would want to wait for a flush for things that are like outputs, and run immediately for things that are like progress.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it definitely came from there. This is actually something I wanted to ask you Joe, i.e. if it's necessary to keep this arg for the tab functions. From your reply above, I'm guessing "no"? In which case, I just always call session$onFlushed(callback, once = TRUE)
instead of giving the user an option... I think this is what makes sense, but I just wanted to confirm
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong preference either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'll remove the argument then; it's more likely it would confuse people, rather than enlighten them... Let me know if you object, @wch
R/insert-ui.R
Outdated
#' @param target The \code{value} of an existing \code{tabPanel()}, next to | ||
#' which \code{tab} will be added. If \code{NULL},the \code{tab} will be | ||
#' placed either as the first tab or the last tab, depending on the | ||
#' \code{position} argument. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would tweak the target=NULL
case. You've got it like this:
- If target is non-
NULL
,right
means "to the right of the target". - If target is
NULL
,right
means "to the right of everything".
This overloading of the meaning of position
feels strange to me. It also means that there are two ways of putting a tab at the end using right
: either use a NULL
target, or use the current last tab as the target; but, there is no way to put a tab at the beginning, without changing the position to left
.
The usual way I've seen this done is:
- If target is non-
NULL
,right
means "to the right of the target". (no change) - If target is
NULL
,right
means "to the right of nothing"--that is, the leftmost position.
This way, the meaning of position
is more consistent. And also both position="right"
and "left"
are capable of addressing any possible slot.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, in your proposed way, what does left
mean when target is NULL
? Is it "to the left of nothing", meaning the rightmost tab? Or is it "to the left of nothing", meaning the leftmost tab just the same?
Regardless, I also wanted to point out something. I chose to use this combination of target
and position
keeping in mind that the most important things would be:
- All possible positions must be covered
- The most typical use cases should be the most intuitive. There isn't much challenging about
position
iftarget
is non-NULL. However, iftarget
is NULL (or whatever value we decide to give to mean that the target is the whole tabset), then we want to allow users to quickly specify whether they want to put the tab at the beginning or at the end (which I think are the only two reasonable options when dealing with the whole tabset as the "target"). So, in this way,right
means "to the right of all other tabs" andleft
means to the "left of all other tabs". This is particularly important when you want to keep either prepending or appending tabs, but you don't know (or don't want to keep track) of the tab value of the first or last tab, respectively.
So, my question is, under your scheme, is there a way that the user can prepend/append tabs without referring to specific tab names? That was the biggest consideration for me. So, while I agree that it is not ideal to have two ways of doing the same thing, I think I care more about the symmetry in being able to both prepend or append tabs without having to know what's the first (or last) tab value?
Let me know what you think :)
srcjs/shinyapp.js
Outdated
prevTabIds.push($(this).find('> a').attr('href').replace(leadingHref,'')); | ||
}); | ||
prevTabIds = prevTabIds.map(Number); | ||
var tabId = Math.max.apply(Math, prevTabIds) + 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be slightly cleaner to use Math.max.apply(null, prevTabIds)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, thanks!
Does this work for Also, for |
srcjs/shinyapp.js
Outdated
throw 'There is no tabsetPanel with id ' + message.tabsetPanelId; | ||
}; | ||
|
||
// This is the JS equivalent of the builtItem() R function that is used |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unfortunate that we have one copy of this code in R and another copy of it in JS.
I've actually never liked that tabsetPanel
s modify their contents on the R side. It might make sense to refactor all that code so that the buildTabset
function is eliminated from the R side, and implemented in JS. To me, that feels like the right place to do the DOM mutations, and it would also allow the code to be reused here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree. This was the most intuitive way of coding for me (i.e. try to understand previous code and work with it, rather than refactoring), but in hindsight, that may have been a better bet from the start. @jcheng5, what do you think? Is it worth it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not so familiar with buildTabset
but I assume the answer to Winston's other question about navbarPage
, navbarMenu
, and navlistPanel
would be a factor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternatively, could all the rendering be done in R, not JS?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume the answer to Winston's other question about
navbarPage
,navbarMenu
, andnavlistPanel
would be a factor
It is and it isn't. So, in answer to @wch's question about whether this works for navbarPage
, navbarMenu
, and navlistPanel
, the answer is no. I guess I assumed tabsetPanel
was the only tabset-thingie that was worth it to implement this for (i.e. the one that people reasonably expect to be able to add and remove tabs). Maybe that's wrong. And this is where we get to @jcheng5's point. Accommodating these scenarios would involve changing my JS (since I only implemented the tabsetPanel
case). This would again be mostly a translation of the R code.
So, if we want to accommodate navbarPage
, navbarMenu
, and navlistPanel
, I think we really have to stop the code duplication and settle on a place/language to put this in.
Like Winston, I think it makes more sense for this to be in the JS side, since that language is perfect for DOM mutations, whereas R isn't.
@wch , @jcheng5, should I go ahead with that? I think the whole refactoring + navbarPage
, navbarMenu
, and navlistPanel
implementations + code review would likely take one more day (assuming all other feedback on the PR is already here).
srcjs/shinyapp.js
Outdated
@@ -730,7 +730,7 @@ var ShinyApp = function() { | |||
prevTabIds.push($(this).find('> a').attr('href').replace(leadingHref,'')); | |||
}); | |||
prevTabIds = prevTabIds.map(Number); | |||
var tabId = Math.max.apply(Math, prevTabIds) + 1; | |||
var tabId = Math.max.apply(null, prevTabIds) + 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's this? Isn't Math
correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought so too (see https://stackoverflow.com/a/1379556/6174455), and it certainly does the same thing. However, looking at the docs (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max), you see they indeed use null
instead of Math
(code chunk from the second example):
function getMaxOfArray(numArray) {
return Math.max.apply(null, numArray);
}
Use this app for quick testing of the current work: https://beta.rstudioconnect.com/content/2917/ Insertion of items into tabsetPanel/navbarPage/navlistPanel is feature complete (this also includes navbarMenus as a special case of navbarPage). I still have to go over the feedback here and address another point that I didn't get to yet (making sure the tabset panel navigates naturally to another tab if the currently selected tab is hidden or removed). I'm planning on taking a look at this later today, so I can leave it as perfect as possible for the code review next week. In any case, the vast, vast majority of this PR is just insertion, so that's why I prioritized that. Update (7/24): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HTML dependencies that are added by appending/inserting/prepending don't work.
library(leaflet)
library(shiny)
ui <- navbarPage(id = "nav", "App")
server <- function(input, output, session) {
appendTab("nav", tabPanel("Map",
"Map:",
leaflet() %>% addTiles()
))
session$onFlushed(function() {
updateTabsetPanel(session, "nav", "Map")
})
}
shinyApp(ui, server)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The "select nearest tab when removing currently active tab" seems not to work if the currently active tab is in a submenu:
library(shiny)
ui <- navbarPage("Tab removal testing", id = "nav",
tabPanel("A", "This is A"),
navbarMenu("B",
tabPanel("C", "This is C"),
tabPanel("D", "This is D")
),
selected = "C",
footer = actionButton("remove", "Remove C")
)
server <- function(input, output, session) {
observeEvent(input$remove, {
removeTab("nav", "C")
})
}
shinyApp(ui, server)
I'd expect hitting the "Remove C" action button to cause tab D to be selected.
I talked to Jonathan McPherson about different strategies for selecting tabs when the current tab goes away. We came up with four reasonable strategies:
- Activate the "nearest" tab. Turns out the logic is surprisingly complex. I don't think the cases enumerated in the code currently are all of them--if you remove the last node in a submenu you'd need to go up to the parent level.
- Activate the most-recently-active, and still visible, tab. This is what RStudio does.
- Always activate the first tab. (Breadth-first or depth-first traversal?)
- "Secret parent"--at the time that the tab is (dynamically) added, take note of which tab is currently active, and record a property on the new tab that the currently active tab is its secret parent. This would be reasonable if the current tab has a button or something on it that causes new tabs to be created; when you close such tabs, you jump back to where you can make new ones.
I don't know how to feel about all of these options. Tempting to just do "first" and be done with it, for now; and add a parameter later if we want users to be able to be more specific about the behavior they want.
Update 2017-08-01: Another half-solution is to have an implicit input that contains the names of all the tabs (and submenus and their tabs). This would at least make it easier for the user to figure out at runtime which tab should be selected (e.g. if they don't keep track of the names of tabs they dynamically insert). Like selecting the last tab would be updateTabsetInput(session, "tabset", selected = tail(input$tabset_tabvalues, 1)
. However this is still not that great because input$xxx_tabvalues
would always reflect the set of tabs at the time the last message was received by the client--you might've already done removals or whatever since then.
It's a bit unfortunate that the new functions all delay until flushed, while Let's have append/prepend/insert take another argument, |
I've addressed the dependencies not loaded, and added a Still need to revisit the logic that fires when the currently active tab is removed/hidden. I talked to Winston about this today and we thought about what kinds of edge cases real-world uses are likely to run into, and we decided to do the simplest thing for now, which is to jump to the first tab every time. |
We need these test cases for anywhere we insert dynamic UI:
I'm pretty sure at least number 1 is failing for |
All outstanding issues* that I know of are fixed. I'd like to code review with @bborgesr and either @wch or @alandipert on Monday, then we can make a decision about whether we think this is safe to merge now, or whether we should delay merging until right after we go to CRAN. * with the tab functionality--not the |
Modules test app: library(shiny)
modUI <- function(id) {
ns <- NS(id)
tagList(
actionButton(ns("insert"), "Test insert + update"),
actionButton(ns("show"), "Test show + update"),
actionButton(ns("update"), "Update tabset"),
tabsetPanel(id = ns("tabs"),
tabPanel("First", "First tab panel"),
tabPanel("Second", "Second tab panel")
)
)
}
mod <- function(input, output, session) {
hideTab("tabs", "Second")
observeEvent(input$insert, {
appendTab("tabs", tabPanel("Dynamic", "Dynamic Tab"), select = TRUE)
})
observeEvent(input$show, {
showTab("tabs", "Second", select = TRUE)
})
observeEvent(input$update, {
updateTabsetPanel(session, "tabs", "Second")
})
}
ui <- fluidPage(
modUI("module")
)
server <- function(input, output, session) {
callModule(mod, "module")
}
shinyApp(ui, server) |
…de a navbarMenu (or the whole menu) and it is selected (before this commit, it wasn't navigating to the first tab like it is supposed to)
R/shiny.R
Outdated
@@ -846,6 +846,17 @@ ShinySession <- R6Class( | |||
registerDataObj = function(name, data, filterFunc) { | |||
.subset2(self, "registerDataObj")(ns(name), data, filterFunc) | |||
}, | |||
sendInsertTab = function(inputId, liTag, divTag, menuName, | |||
target, position, select) { | |||
.subset2(self, "sendInsertTab")(ns(inputId), liTag, divTag, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of reimplementing these, can you just have the "real" implementations wrap the inputId
with session$ns()
?
srcjs/shinyapp.js
Outdated
function ensureTabsetHasVisibleTab($tabset) { | ||
if ($tabset.find("li.active").length === 0) { | ||
if ($tabset.find("li.active").not(".dropdown").length === 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I didn't even realize a dropdown could be active
You shouldn't change this now in any case, but in the future, I'd avoid making arrays of jQuery objects and then iterating over their contents (like in |
…le functionality (rather than overriding the same functions in `makeScope`)
@jcheng5 Re your last comment, I did start that way (the |
I just tried using appendTab to dynamically add tabPanels but it always displays the content of the first added tabPanel although I set 'select' to TRUE. The prepend has the same issue.
|
@keqiang I think the problem is that you are using |
@wch Thanks! Yes, I noticed it happens only when it's empty, but I feel a lot of times I will programmatically add all my tabs and to me append shouldn't require any existing element. I will open a new issue. |
This introduces 6 new functions to Shiny:
insertTab
(signature:insertTab(inputId, tab, target, position = c("before", "after"))
)prependTab
(signature:prependTab(inputId, tab, menuName = NULL)
)appendTab
(signature:appendTab(inputId, tab, menuName = NULL)
)removeTab
(signature:removeTab(inputId, target)
)hideTab
(signature:hideTab(inputId, target)
)showTab
(signature:showTab(inputId, target)
)An app for dynamic testing and exploring (includes all possibilities): https://beta.rstudioconnect.com/content/2917/
Here are the three UI tabset-like containers that these functions apply to (i.e. an instance of one of these things must be provided to the desired function through the
inputId
arg):navbarPage
(includingnavbarMenu
-- see more below)tabsetPanel
(bothtype
s)navlistPanel
There is also the case of
navbarMenu
, which is a dropdown menu that anavbarPage
can have (instead of the standardtabPanel
items, though those are fine as well). AnavbarMenu
can be inserted/removed/hidden/shown, just like atabPanel
(for thetarget
argument, supply themenuName
of thenavbarMenu
, instead of thevalue
of thetabPanel
). You can also add moretabPanel
s or text (dividers and header) inside anavbarMenu
.For example, consider we have a
navbarPage
(with id "navbarPage") that has anavbarMenu
(with menuName "menu"), which itself includes another two tabs ("Foo" and "Bar"). Then, usinginsertTab(inputId = "navbarPage", target = "Foo", tab = ...)
will insert thetab
before "Foo" (notice how you don't need to supply the name of thenavbarMenu
, sincetarget = "Foo"
already gives us the necessary info).If you instead want to prepend/append a tab to the beginning/end of a
navbarMenu
, you do need to supply themenuName
:prependTab(inputId = "navbarPage", menuName = "menu", tab = ...)
appendTab(inputId = "navbarPage", menuName = "menu", tab = ...)