Welcome to Dash Crush, your walk-through tutorial to advanced interactive Data Viz dashboards using Dash Plotly.
In the previous part, we learned how to create a custom dbc.Card
with a selection dropdown, an expand button in the header, and a collapsible body with dynamic content.
In this chapter, we will continue our adventure with dynamic layouts in Dash Plotly. Using the code from the previous chapter, we will learn how to handle not one but multiple cards: add them, remove them, and change their order.
TL;DR
In this chapter:
- How to add/remove content dynamically
- How to reorder components
Introduction
The card represents a single layer of material and its properties. We aim to use multiple cards to define the geometry of a multilayered structure.
The code
We will reuse the code from the previous chapter. The crucial parts are the card component and callbacks. Therefore, I will describe the changes made on top of them below.
Install packages
Create and activate your virtual environment and install the following packages:
pip install dash pandas dash-bootstrap-components
The deck of cards
Let's name a collection of cards a deck. It will be a container with all the cards as its children. It will be located in src/layout/deck.py
.
# src/layout/deck.py
from dash import html
def deck():
return html.Div(children=[], id="deck")
Before we add anything to it, it's just an empty div.
The "Add" button
To populate a deck, we need an option for the user to add a new layer. It will be an "Add" button located in the Button bar. The definition will be located in src/layout/add_bar.py
.
# src/layout/add_bar.py
def add_bar():
"""Add button bar"""
return dbc.Row(
[
dbc.Col(
[
dbc.Button(
[html.I(className="fi-plus"), "Layer"],
id="add_card_button",
color="primary",
style={"width": "100%"},
)
],
width={"size": 6, "offset": 6},
)
]
)
The spacer
In a similar way as in one of the past chapters, we define the spacer
component in src/layout/grid.py
:
# src/layout/grid.py
import dash_bootstrap_components as dbc
def spacer(height):
"""Add empty row"""
return dbc.Row(style={"height": f"{height}px"})
The layout
We modify the top-level layout to contain the "Add" button and the deck in a single column centered on the screen. The code is located in src/layout/layout.py
.
# src/layout/layout.py
import dash_bootstrap_components as dbc
from src.layout.add_bar import add_bar
from src.layout.deck import deck
from src.layout.grid import spacer
from src.layout.style import COLORS
def create_layout():
"""Build layout"""
return dbc.Container(
[
dbc.Row(
[
dbc.Col(
[spacer(10), add_bar(), spacer(10), deck()],
width={"size": 2, "offset": 5},
className="scrollable-pane",
style={"backgroundColor": COLORS["light-gray"]},
)
]
),
],
fluid=True,
)
Modifying the card
We implemented the "Add" button. However, we still have no way of removing the card. Consequently, we will add a "Remove" button to every card to implement that functionality. Additionally, we will add "Move up" / "Move down" buttons to enable the user to change the cards' order.
Let's define button_group
in src/layout/card.py
containing before the items mentioned:
# src/layout/cards.py
def button_group(index):
"""Group functional buttons together"""
return dbc.ButtonGroup(
[
dbc.Button(
html.I(className="fi-arrow-up"),
color="primary",
outline=True,
id={"role": "card_up", "index": index},
size="sm",
className="btn-icon",
),
dbc.Button(
html.I(className="fi-arrow-down"),
color="primary",
outline=True,
id={"role": "card_down", "index": index},
size="sm",
className="btn-icon",
),
dbc.Button(
html.I(className="fi-x"),
color="primary",
outline=True,
id={"role": "card_remove", "index": index},
size="sm",
className="btn-icon",
),
]
)
ButtonGroup
is a dbc
component rendering several buttons as a continuous group.
Finally, we need to place our new button on card_header
. We will do so by adding button_group(index)
to the header's InputGroup
:
card_header = dbc.CardHeader(
dbc.Row(
[
dbc.InputGroup(
[
dbc.Button(
html.I(className="fi-play"),
id={"role": "card_toggle", "index": index},
size="sm",
className=expand_class,
disabled=expand_disabled,
),
dbc.Select(
id={"role": "material_dropdown", "index": index},
options=deepcopy(material_options),
value=material,
),
button_group(index),
],
size="sm",
)
]
)
)
The rest of the layout remains unchanged. However, the most interesting things happen not in the layout but in callbacks.
Callbacks
The deck callback
We will handle all the possible events (addition, removal, or moving a card) in single callbacks as they all modify the deck
's children
property. The code is located in src/callbacks/deck.py
# src/callbacks/deck.py
import json
import dash
from dash import Input, Output, State, ALL
from src.callbacks.utils import get_caller_id
from src.layout.dropdowns import MATERIAL_OPTIONS
from src.layout.card import empty_layer
def create_callbacks(app):
"""Create deck callbacks"""
@app.callback(
Output("deck", "children"),
Input("add_card_button", "n_clicks"),
Input({"role": "card_remove", "index": ALL}, "n_clicks"),
Input({"role": "card_up", "index": ALL}, "n_clicks"),
Input({"role": "card_down", "index": ALL}, "n_clicks"),
State("deck", "children"),
prevent_initial_call=True,
)
def _handle_deck(
add_clicks,
remove_clicks,
up_clicks,
down_clicks,
current_cards,
): # pylint: disable=unused-argument
"""Add, remove or move layer card"""
caller_id = get_caller_id(dash.callback_context)
out_cards = current_cards
if caller_id == "add_card_button":
out_cards.append(empty_layer(add_clicks, MATERIAL_OPTIONS))
else:
button = json.loads(caller_id)
if button["role"] == "card_remove":
out_cards = []
for card in current_cards:
cid = card["props"]["id"]
if cid["index"] != button["index"]:
out_cards.append(card)
elif button["role"] == "card_up":
deck_idx = _find_index(out_cards, button["index"])
if deck_idx is not None and deck_idx > 0:
_swap_cards(out_cards, deck_idx, deck_idx - 1)
elif button["role"] == "card_down":
deck_idx = _find_index(out_cards, button["index"])
if deck_idx is not None and deck_idx < (len(out_cards) - 1):
_swap_cards(out_cards, deck_idx, deck_idx + 1)
return out_cards
Let's go through the callbacks' code. There are 4 use cases we need to handle:
- Addition (i.e.,
add_card_button
clicked): we append a new empty layer card. The card index will be then_clicks
property of the "Add" button. Since this property is monotonically increasing, it provides a unique ID for a new card. - Deletion: we look for a matching ID and copy all input cards except the matching one to the output.
- Moving a card up: we search for a matching card index, and if found, swap the card found with the preceding one.
- Moving a card down: We look for a matching card index, and if found, swap the card found with the following one.
One more thing to clarify: the "Add" button has a plain-text ID, while all the other IDs are compound IDs stored as a JSON string. That's why we need to handle the "Add" button first. Detection of other cases requires loading the JSON string to dict and inspecting the role
field.
At this point, we are still missing helper functions _find_index
and swap_cards
. They are straightforward and look like this:
def _find_index(card_list, button_index):
"""find card id in list"""
for idx, card in enumerate(card_list):
cid = card["props"]["id"]
if cid["index"] == button_index:
return idx
return None
def _swap_cards(card_list, aidx, bidx):
"""swap two cards"""
card_list[aidx], card_list[bidx] = card_list[bidx], card_list[aidx]
The first function (_find_index
) looks for the card's list index idx
in the deck
's children list of that index
field, the same as the triggering button. (Note, that the index
field and list index idx
are different entities). The second function (swap_cards
) swaps two cards in the list.
Using the callback
Finally, we need to create all the callbacks. In dashboard.py
, change the callback creation to:
# dashboard.py
import src.callbacks.card.create_callbacks
import src.callbacks.deck.create_callbacks
def create_callbacks(app):
"""Create all callbacks"""
src.callbacks.card.create_callbacks(app)
src.callbacks.deck.create_callbacks(app)
And creating callbacks in the usual way:
create_callbacks(APP)
Starting the app:
Start the app with python dashboard.py
. You should see an indication of a running app in your console. The dashboard is available under http://localhost:8050.
Summary:
A quick recap:
- Stick to the strict IDs convention when adding dynamic components
- Pass
ALL
relevant components' properties to a patching-matching callback - Evaluate
dash.context
to find out what action triggered the callback
The complete code from this chapter can be found on GitHub:
https://github.com/ptrhbt/dash-crush/tree/4-dynamic-layout-2-deck
There is also the file requirements.txt
which lists all packages used. You can install it to your virtual env with pip install -r requirements.txt
.
I would be happy to hear your opinions about Dash Crush.
See you in the next chapter of Dash Crush! We will continue exploring dynamic layouts and learn how to extract data from dynamic components.