Search code examples
pythonpy-shiny

How to preserve input and output values when adding new tabs?


I'm encountering a peculiar issue when developing my Python shiny app. My app currently has the functionality to dynamically generate new tabs with the press of a navset tab called "+". However, after pressing "+", the state (including input and output values) of the previous tabs reset back to empty. Is there a way to preserve the state of any previously existing tabs?

My code is outlined below:

from shiny import App, Inputs, Outputs, Session, module, reactive, render, ui

# Create a Module UI
@module.ui
def textbox_ui(panelNum):
    return ui.nav_panel(
        f"Tab {panelNum}",
        ui.input_text_area(id=f"test_text",
                           label = "Enter some text"),
        ui.output_ui(f"display_text_{panelNum}"),
        value = f"Tab_{panelNum}"
    )

# Set up module server
@module.server
def textbox_server(input, output, session, panelNum):

    @output(id=f"display_text_{panelNum}")
    @render.text
    def return_text():
       return input[f"test_text"]()


# Set up app UI
app_ui = ui.page_fluid(
    ui.tags.head(
       ui.tags.style(
           """
           body {
               height: 100vh;
               overflow-y: auto !important;
           }
           """
       )
    ),
    ui.h2('Test App', align='center', fillable=True),
    ui.output_ui("tab_UI"),
    title = "Test App"
)

# Set up server
def server(input, output, session):
   
   # Set up reactive values
   navs = reactive.value(0)
   
   # Add tabs if the user presses "+"
   @reactive.effect
   @reactive.event(input.shiny_tabs)
   def add_tabs():
      if input.shiny_tabs() == "+":
          navs.set(navs.get() + 1)
   
   @output
   @render.ui
   def tab_UI():
      [textbox_server(str(x), panelNum=x+1) for x in range(navs.get())]
      ui.update_navs("shiny_tabs", selected = f"Tab_{navs.get()}")

      return ui.navset_tab(
         ui.nav_panel("Home",
                      ui.card(
                         ui.card_header("Overview"),
                         ui.p("An example of the outputs clearing")
                         ),
                      value = "panel0"
                      ),
        *[textbox_ui(str(x), panelNum=x+1) for x in range(navs.get())],
        ui.nav_panel("+"),
        id = "shiny_tabs"
     )

app = App(app_ui, server)

Solution

  • The reason for the reset is that the navset_tab is re-rendered each time a new nav_panel gets appended. So an approach would be better where we have the navset_tab outside of the server and then append a nav_panel on click without re-rendering everything.

    The difficulty is on the one hand that ui.insert_ui does not seem to be suitable for the insert and on the other hand that Shiny for Python currently does not carry functions for dynamic navs, see e.g. posit-dev/py-shiny#089.

    However, within the PR#90 is a draft for an insert function nav_insert which is suitable for this application. I adapted this below and re-wrote your app, we now only insert a new tab if the button is clicked, the rest stays stable.

    enter image description here

    import sys
    from shiny import App, reactive, ui, Session, Inputs, Outputs, module, render
    from shiny._utils import run_coro_sync
    from shiny._namespaces import resolve_id
    from shiny.types import NavSetArg
    from shiny.session import require_active_session
    from typing import Optional, Union
    
    if sys.version_info >= (3, 8):
        from typing import Literal
    else:
        from typing_extensions import Literal
    
    # adapted from https://github.com/posit-dev/py-shiny/pull/90/files
    def nav_insert(
        id: str,
        nav: Union[NavSetArg, str],
        target: Optional[str] = None,
        position: Literal["after", "before"] = "after",
        select: bool = False,
        session: Optional[Session] = None,
    ) -> None:
    
        """
        Insert a new nav item into a navigation container.
        Parameters
        ----------
        id
            The ``id`` of the relevant navigation container (i.e., ``navset_*()`` object).
        nav
            The navigation item to insert (typically a :func:`~shiny.ui.nav` or
            :func:`~shiny.ui.nav_menu`). A :func:`~shiny.ui.nav_menu` isn't allowed when the
            ``target`` references an :func:`~shiny.ui.nav_menu` (or an item within it). A
            string is only allowed when the ``target`` references a
            :func:`~shiny.ui.nav_menu`.
        target
            The ``value`` of an existing :func:`shiny.ui.nav` item, next to which tab will
            be added. Can also be ``None``; see ``position``.
        position
            The position of the new nav item relative to the target nav item. If
            ``target=None``, then ``"before"`` means the new nav item should be inserted at
            the head of the navlist, and ``"after"`` is the end.
        select
            Whether the nav item should be selected upon insertion.
        session
            A :class:`~shiny.Session` instance. If not provided, it is inferred via
            :func:`~shiny.session.get_current_session`.
       
        """
        
        session = require_active_session(session)
    
        li_tag, div_tag = nav.resolve(
            selected=None, context=dict(tabsetid="tsid", index="id")
        )
    
        msg = {
            "inputId": resolve_id(id),
            "liTag": session._process_ui(li_tag),
            "divTag": session._process_ui(div_tag),
            "menuName": None,
            "target": target,
            "position": position,
            "select": select,
        }
    
        def callback() -> None:
            run_coro_sync(session._send_message({"shiny-insert-tab": msg}))
    
        session.on_flush(callback, once=True)
        
    @module.ui
    def textbox_ui(panelNum):
        return ui.nav_panel(
            f"Tab {panelNum}",
            ui.input_text_area(id=f"test_text_{panelNum}",
                               label = "Enter some text"),
            ui.output_ui(f"display_text_{panelNum}"),
            value = f"Tab_{panelNum}"
        )
        
    @module.server
    def textbox_server(input, output, session, panelNum):
    
        @output(id=f"display_text_{panelNum}")
        @render.text
        def return_text():
            return input[f"test_text_{panelNum}"]()
        
    app_ui = ui.page_fluid(
        ui.tags.head(
           ui.tags.style(
               """
               body {
                   height: 100vh;
                   overflow-y: auto !important;
               }
               """
           )
        ),
        ui.h2('Test App', align='center', fillable=True),
        ui.navset_tab(
             ui.nav_panel("Home",
                          ui.card(
                             ui.card_header("Overview"),
                             ui.p("An example of the outputs not clearing")
                             ),
                          value = "Tab_0"
                          ),
            ui.nav_panel("+"),
            id = "shiny_tabs"
        ),
        title = "Test App"
    )
    
    def server(input: Inputs, output: Outputs, session: Session):
        
        navCounter = reactive.value(0)
        
        @reactive.effect
        @reactive.event(input.shiny_tabs)
        def add_tabs():
            if input.shiny_tabs() == "+":
                navCounter.set(navCounter.get() + 1)
               
                id = str(navCounter.get())
                idPrev = str(navCounter.get() - 1)
                nav_insert(
                    "shiny_tabs",
                    textbox_ui(id, id),
                    target=f"Tab_{idPrev}",
                    position="after",
                    select=True
                )
                
                textbox_server(id, id)
    
    app = App(app_ui, server)