ETC5523: Communicating with Data

Workshop 10: Build a multi-tab Shiny dashboard

Author

Michael Lydeamore

Published

1 October 2001

🎯 Objectives

By the end of this workshop, you will be able to:

  • construct a {bslib} navset_card_tab() from several nav_panel() components;
  • connect outputs in different tabs to shared reactive calculations;
  • use the selected tab as a reactive input;
  • move the user to a panel with nav_select(); and
  • show controls only when they are relevant to the active tab.
What you will build

You will turn the Week 10 solar app into a four-tab dashboard. Three tabs reserve space for different views of the results. A fourth tab exposes an interval-by-interval calculation trace.

The focus today is the technical structure of a multi-tab app. The tutorial will build the detailed content inside the tabs.

Preparation

Download the tutorial starter app and household energy data. Save them in the same folder.

Make a copy of starter_app.R called workshop_app.R. Work in the copy so that you can compare your code with the supplied starter later.

Run the app once before changing anything.

The app uses library(bslib). All layout components in this workshop are standard functions documented on the {bslib} website.

Tip

Run the app after every stage. If it stops working, the most recent edit is the first place to look.

1. Trace the existing reactive structure (5 minutes)

Locate these three reactive expressions in server():

scenario_data()
metrics()
daily_data()

Draw arrows showing which reactive calls which:

inputs Β· scenario_data() Β· metrics() Β· daily_data() Β· outputs

Then answer:

  1. Which reactive performs the interval solar and battery calculations?
  2. Which reactive aggregates interval rows into daily rows?
  3. If the system-size slider changes, which objects become invalidated?
  4. Why should a tab call these reactives instead of rebuilding the scenario itself?

The main dependency paths are:

inputs ──> scenario_data() ──> metrics() ──> text/table outputs
                    β”‚
                    └────────> daily_data() ──> plot outputs
                                  β”‚
                    metrics() β”€β”€β”€β”€β”˜

scenario_data() performs the interval calculations. daily_data() aggregates the interval rows. A system-size change invalidates scenario_data() and therefore every downstream reactive and output that uses it.

Reusing these reactives gives every tab the same scenario and avoids duplicated calculation code.

2. Reconstruct the tabbed navset (7 minutes)

In the body of page_sidebar(), replace the existing navset_card_tab() with an empty four-panel structure:

navset_card_tab(
  id = "dashboard_tab",
  nav_panel("Why invest", value = "sales"),
  nav_panel("Energy balance", value = "neutral"),
  nav_panel("Cautious case", value = "cautious"),
  nav_panel("Evidence", value = "evidence")
)

Run the app and check that:

  • four tabs appear;
  • clicking a tab changes the selected panel; and
  • the sidebar remains visible because it belongs to page_sidebar(), outside the navset.

Add a short placeholder to each of the first three panels. Use indentation to make the UI hierarchy easy to see.

navset_card_tab(
  id = "dashboard_tab",
  nav_panel(
    "Why invest",
    value = "sales",
    h3("Sales view"),
    p("This tab will be completed in the tutorial.")
  ),
  nav_panel(
    "Energy balance",
    value = "neutral",
    h3("Neutral view"),
    p("This tab will show the daily energy balance.")
  ),
  nav_panel(
    "Cautious case",
    value = "cautious",
    h3("Cautious view"),
    p("This tab will be completed in the tutorial.")
  ),
  nav_panel(
    "Evidence",
    value = "evidence",
    h3("Calculation trace")
  )
)

The navset id makes the selected panel available in the server as input$dashboard_tab. Explicit values keep the server logic independent of the visible labels.

3. Connect the neutral tab to existing outputs (8 minutes)

The starter server already creates:

  • output$neutral_headline;
  • output$neutral_message; and
  • output$neutral_plot.

Place the matching output functions inside the Energy balance tab:

h3(textOutput("neutral_headline", inline = TRUE))
textOutput("neutral_message")
plotOutput("neutral_plot", height = "430px")

Run the app and change the system size and date range.

For each of the three outputs, write down:

UI placeholder Server renderer Shared reactive used
textOutput("neutral_headline")
textOutput("neutral_message")
plotOutput("neutral_plot")
UI placeholder Server renderer Shared reactive used
textOutput("neutral_headline") renderText() none: the text is currently fixed
textOutput("neutral_message") renderText() metrics()
plotOutput("neutral_plot") renderPlot() daily_data()

An output ID is a contract between the UI and server. For example, plotOutput("neutral_plot") must match output$neutral_plot <- renderPlot({...}) exactly.

Deliberately change one UI ID to "neutral_plots". Run the app, observe the empty space, and then repair the ID.

4. Add a calculation-trace tab (9 minutes)

The Evidence tab will help you inspect the calculation that feeds every other tab.

Add this placeholder to that tab:

tableOutput("calculation_trace")

In server(), create output$calculation_trace with renderTable(). It should:

  1. call scenario_data();
  2. keep the first eight rows; and
  3. display the time, observed consumption, modelled solar, net demand, battery state, grid import and grid export.

Round numeric values to two decimal places.

Add this to server():

output$calculation_trace <- renderTable({
  scenario_data() |>
    slice_head(n = 8) |>
    transmute(
      Time = format(datetime, "%d %b %H:%M"),
      Consumption = round(energy_kwh, 2),
      Solar = round(solar_kwh, 2),
      `Net demand` = round(net_demand_kwh, 2),
      Battery = round(battery_state_kwh, 2),
      Import = round(grid_import_kwh, 2),
      Export = round(grid_export_kwh, 2)
    )
})

This output exposes intermediate values but does not create a second version of the calculation. It reads from the same scenario_data() used elsewhere.

Change the date range and confirm that the first timestamp in the trace changes.

5. Treat the selected tab as an input (7 minutes)

Because the tabset has an ID, Shiny stores the selected tab in input$dashboard_tab.

Add this immediately before navset_card_tab(), then render its value in the server:

verbatimTextOutput("active_tab")

output$active_tab <- renderText({
  paste("Active tab:", input$dashboard_tab)
})

Now add an action button in the page body, immediately before navset_card_tab():

actionButton("show_evidence", "Inspect the calculation")

Use observeEvent() and nav_select() to move to the Evidence panel when the button is clicked.

Add this to server():

observeEvent(input$show_evidence, {
  nav_select("dashboard_tab", selected = "evidence")
})

There are two reactive directions here:

  • a user click changes input$dashboard_tab; and
  • the server can change the selected panel with nav_select().

The selected value must match the panel’s explicit value.

Test the button from each of the other three tabs.

6. Show a control only on the relevant tab (6 minutes)

Add this inside sidebar():

conditionalPanel(
  condition = "input.dashboard_tab == 'evidence'",
  sliderInput(
    "trace_rows",
    "Rows in calculation trace",
    min = 4,
    max = 48,
    value = 8,
    step = 4
  )
)

Update the calculation table so that it uses input$trace_rows instead of always showing eight rows.

The first part of the table code becomes:

output$calculation_trace <- renderTable({
  scenario_data() |>
    slice_head(n = input$trace_rows) |>
    transmute(
      # the same columns as before
    )
})

conditionalPanel() is evaluated in the browser, so its condition uses JavaScript syntax. The input still exists while hidden; hiding a control does not remove its value from the server.

Confirm that the slider appears only on the Evidence tab and that it changes the number of table rows.

7. Technical check before the tutorial (3 minutes)

Test the finished app systematically:

Test Expected result
Select each tab The correct panel appears
Change system size Neutral outputs and trace update
Change date range Plot, message and trace update
Click Inspect the calculation Evidence becomes active
Move the trace-row slider Table length changes
Exclude the battery Battery state becomes zero

Then search your server code for add_solar_model( and dispatch_battery(. Each should be called only inside scenario_data().

Important

A tab is a container, not a separate app. The dashboard should have one input state and one calculation pipeline, with several outputs reading from it.

Extension

Replace navset_card_tab() temporarily with another standard {bslib} navset:

navset_pill(
  id = "dashboard_tab",
  nav_panel("Why invest", value = "sales", ...),
  nav_panel("Energy balance", value = "neutral", ...),
  nav_panel("Cautious case", value = "cautious", ...),
  nav_panel("Evidence", value = "evidence", ...)
)

The panel contents and server code do not change. This separation between navigation, content and calculation is one advantage of standard {bslib} components.

Workshop check-out

Complete these statements:

A tabset becomes reactive when…

UI and server outputs are connected by…

Shared reactives are preferable to tab-specific calculations because…