refactor: replace dmc.Drawer with dmc.Modal for filter selection (Task 7.4 + 7.5)

- Created 3 separate modals: Drug Selection (lg), Trust Selection (sm),
  Directorate Browser (xl) with centered overlay
- Added filter trigger buttons to filter bar with count badges
- Added "Clear All" button in filter bar for global filter reset
- Per-modal clear buttons for drugs and trusts
- Preserved all existing selection logic (same component IDs)
- Deleted drawer.py component and callbacks (replaced by modals.py)
- Updated CSS: filter-btn styles, modal chip/badge styles
This commit is contained in:
Andrew Charlwood
2026-02-06 15:42:48 +00:00
parent 0cb63146dd
commit f2c5b2645e
9 changed files with 488 additions and 315 deletions
+15 -15
View File
@@ -295,29 +295,29 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_
- **Checkpoint**: Sidebar shows chart view options, no placeholder items, app runs without errors. - **Checkpoint**: Sidebar shows chart view options, no placeholder items, app runs without errors.
### 7.4 Replace dmc.Drawer with dmc.Modal for filter selection ### 7.4 Replace dmc.Drawer with dmc.Modal for filter selection
- [ ] **Problem**: The single dmc.Drawer with drugs + trusts + directorates requires excessive scrolling and is confusing (multiple sidebar buttons all open the same drawer) - [x] **Problem**: The single dmc.Drawer with drugs + trusts + directorates requires excessive scrolling and is confusing (multiple sidebar buttons all open the same drawer)
- [ ] **Solution**: Replace `dmc.Drawer` with `dmc.Modal` dialogs. Create separate modals: - [x] **Solution**: Replace `dmc.Drawer` with `dmc.Modal` dialogs. Create separate modals:
- Drug Selection modal (contains the All Drugs ChipGroup) - Drug Selection modal (contains the All Drugs ChipGroup)
- Trust Selection modal (contains the Trust ChipGroup) - Trust Selection modal (contains the Trust ChipGroup)
- Directorate Browser modal (contains the nested directorate accordion with indication sub-items and drug fragment badges) - Directorate Browser modal (contains the nested directorate accordion with indication sub-items and drug fragment badges)
- [ ] Each modal is opened by its corresponding button in the filter bar (see 7.5) - [x] Each modal is opened by its corresponding button in the filter bar (see 7.5)
- [ ] Modals should be appropriately sized (`size="lg"` or `size="xl"`) and use `dmc.Modal` with `centered=True` - [x] Modals should be appropriately sized (`size="lg"` or `size="xl"`) and use `dmc.Modal` with `centered=True`
- [ ] Preserve all existing selection logic: ChipGroup values, fragment matching, clear button - [x] Preserve all existing selection logic: ChipGroup values, fragment matching, clear button
- [ ] Consider having a shared "Clear All Filters" mechanism accessible from each modal or from the filter bar - [x] Consider having a shared "Clear All Filters" mechanism accessible from each modal or from the filter bar
- [ ] Delete `dash_app/components/drawer.py` after modals are working, or refactor it into a `modals.py` - [x] Delete `dash_app/components/drawer.py` after modals are working, or refactor it into a `modals.py`
- [ ] **Use the frontend-developer agent** to determine optimal modal layout, sizing, and UX patterns. The agent should review the data shapes (42 drugs, 7 trusts, 19 directorates × 163 indications) and recommend the best modal organization. - [x] **Use the frontend-developer agent** to determine optimal modal layout, sizing, and UX patterns. The agent should review the data shapes (42 drugs, 7 trusts, 19 directorates × 163 indications) and recommend the best modal organization.
- **Checkpoint**: Each filter has its own modal, selection works, no excessive scrolling, chart updates correctly. - **Checkpoint**: Each filter has its own modal, selection works, no excessive scrolling, chart updates correctly.
### 7.5 Move filter triggers to the top filter bar ### 7.5 Move filter triggers to the top filter bar
- [ ] **Problem**: Filter buttons are in the sidebar, which should be for navigation/views, not filters. Filters should be in the persistent top filter bar. - [x] **Problem**: Filter buttons are in the sidebar, which should be for navigation/views, not filters. Filters should be in the persistent top filter bar.
- [ ] **Add** to the filter bar (alongside existing chart-type toggle and date dropdowns): - [x] **Add** to the filter bar (alongside existing chart-type toggle and date dropdowns):
- "Drugs" button that opens the Drug Selection modal (show count badge when drugs are selected, e.g. "Drugs (3)") - "Drugs" button that opens the Drug Selection modal (show count badge when drugs are selected, e.g. "Drugs (3)")
- "Trusts" button that opens the Trust Selection modal (show count badge) - "Trusts" button that opens the Trust Selection modal (show count badge)
- "Directorates" button that opens the Directorate Browser modal (show count badge) - "Directorates" button that opens the Directorate Browser modal (show count badge)
- "Clear All" button to reset all filter selections - "Clear All" button to reset all filter selections
- [ ] The filter bar should remain static across all chart views (icicle, sankey, timeline) — it's the global filter control - [x] The filter bar should remain static across all chart views (icicle, sankey, timeline) — it's the global filter control
- [ ] Update callback wiring: filter bar buttons → open corresponding modal; modal selections → app-state → chart-data → chart - [x] Update callback wiring: filter bar buttons → open corresponding modal; modal selections → app-state → chart-data → chart
- [ ] Remove drawer-related sidebar callbacks (`open_drawer` in `dash_app/callbacks/drawer.py`) - [x] Remove drawer-related sidebar callbacks (`open_drawer` in `dash_app/callbacks/drawer.py`)
- **Checkpoint**: Filter bar has drug/trust/directorate buttons with count badges, each opens correct modal, filter bar is visible across all views. - **Checkpoint**: Filter bar has drug/trust/directorate buttons with count badges, each opens correct modal, filter bar is visible across all views.
--- ---
@@ -329,7 +329,7 @@ All tasks marked `[x]` AND:
- [x] Layout matches 01_nhs_classic.html (header, sidebar, KPIs, filter bar, chart card, footer) - [x] Layout matches 01_nhs_classic.html (header, sidebar, KPIs, filter bar, chart card, footer)
- [x] Icicle chart renders with real SQLite data (pathway_nodes) - [x] Icicle chart renders with real SQLite data (pathway_nodes)
- [x] Date filters + chart type toggle update chart correctly - [x] Date filters + chart type toggle update chart correctly
- [ ] Filter modals open correctly for drugs, trusts, and directorates - [x] Filter modals open correctly for drugs, trusts, and directorates
- [x] Selecting a drug filters the chart correctly (no "multiple implied roots" error) - [x] Selecting a drug filters the chart correctly (no "multiple implied roots" error)
- [x] "All Drugs" card allows selecting any drug across all contexts - [x] "All Drugs" card allows selecting any drug across all contexts
- [x] "Clear Filters" resets all selections - [x] "Clear Filters" resets all selections
@@ -337,7 +337,7 @@ All tasks marked `[x]` AND:
- [x] No Reflex imports in `dash_app/` - [x] No Reflex imports in `dash_app/`
- [x] No duplicate component ID errors on first load - [x] No duplicate component ID errors on first load
- [x] Sidebar shows chart views (icicle/sankey/timeline), not filter triggers - [x] Sidebar shows chart views (icicle/sankey/timeline), not filter triggers
- [ ] Filter bar has drug/trust/directorate trigger buttons with selection count badges - [x] Filter bar has drug/trust/directorate trigger buttons with selection count badges
--- ---
+2 -2
View File
@@ -8,7 +8,7 @@ from dash_app.components.kpi_row import make_kpi_row
from dash_app.components.filter_bar import make_filter_bar from dash_app.components.filter_bar import make_filter_bar
from dash_app.components.chart_card import make_chart_card from dash_app.components.chart_card import make_chart_card
from dash_app.components.footer import make_footer from dash_app.components.footer import make_footer
from dash_app.components.drawer import make_drawer from dash_app.components.modals import make_modals
app = Dash( app = Dash(
__name__, __name__,
@@ -34,7 +34,7 @@ app.layout = dmc.MantineProvider(
# Page structure # Page structure
make_header(), make_header(),
make_sidebar(), make_sidebar(),
make_drawer(), make_modals(),
html.Main( html.Main(
className="main", className="main",
children=[ children=[
+39 -14
View File
@@ -259,30 +259,55 @@ body {
.chart-tab:hover:not(.chart-tab--active) { color: var(--nhs-dark-grey); } .chart-tab:hover:not(.chart-tab--active) { color: var(--nhs-dark-grey); }
.chart-tab:focus-visible { box-shadow: inset 0 0 0 3px var(--nhs-yellow); } .chart-tab:focus-visible { box-shadow: inset 0 0 0 3px var(--nhs-yellow); }
/* ── Drug Browser Drawer ── */ /* ── Filter Bar Buttons ── */
.drawer-section { .filter-btn {
padding: 4px 0; display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px;
font-size: 14px; font-weight: 600;
font-family: inherit;
color: var(--nhs-dark-grey);
background: var(--nhs-white);
border: 1px solid var(--nhs-pale-grey);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
} }
.drawer-section-title { .filter-btn:hover { background: #F0F4F5; border-color: var(--nhs-blue); }
margin-bottom: 4px; .filter-btn:focus-visible { box-shadow: 0 0 0 3px var(--nhs-yellow); z-index: 1; }
.filter-btn--clear {
color: var(--nhs-mid-grey);
border-color: transparent;
background: transparent;
font-weight: 400;
} }
.drawer-chips-wrap { .filter-btn--clear:hover { color: var(--nhs-red); background: transparent; }
margin-top: 8px; .filter-btn__badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px;
padding: 0 6px;
font-size: 11px; font-weight: 700;
color: var(--nhs-white);
background: var(--nhs-blue);
border-radius: 10px;
line-height: 1;
} }
.drawer-chips-wrap .mantine-Chip-label { .filter-btn__badge--hidden { display: none; }
/* ── Filter Modals ── */
.modal-chips-scroll {
max-height: 60vh;
overflow-y: auto;
}
.modal-chips-scroll .mantine-Chip-label {
font-family: 'Source Sans 3', Arial, sans-serif; font-family: 'Source Sans 3', Arial, sans-serif;
} }
.drawer-drug-badge { .modal-drug-badge {
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
.drawer-drug-badge:hover { .modal-drug-badge:hover {
filter: brightness(0.9); filter: brightness(0.9);
} }
.drawer-directorate-accordion { .modal-directorate-accordion {
margin-top: 8px;
}
.drawer-clear-btn {
margin-top: 8px; margin-top: 8px;
} }
+2 -2
View File
@@ -6,9 +6,9 @@ def register_callbacks(app):
from dash_app.callbacks.filters import register_filter_callbacks from dash_app.callbacks.filters import register_filter_callbacks
from dash_app.callbacks.chart import register_chart_callbacks from dash_app.callbacks.chart import register_chart_callbacks
from dash_app.callbacks.kpi import register_kpi_callbacks from dash_app.callbacks.kpi import register_kpi_callbacks
from dash_app.callbacks.drawer import register_drawer_callbacks from dash_app.callbacks.modals import register_modal_callbacks
register_filter_callbacks(app) register_filter_callbacks(app)
register_chart_callbacks(app) register_chart_callbacks(app)
register_kpi_callbacks(app) register_kpi_callbacks(app)
register_drawer_callbacks(app) register_modal_callbacks(app)
-68
View File
@@ -1,68 +0,0 @@
"""Callbacks for drug selection: fragment matching and clear filters.
The open_drawer callback was removed in Task 7.3 (sidebar restructure) because
the sidebar no longer has filter trigger items. Task 7.4 will replace the drawer
with modals opened from the filter bar.
"""
from dash import Input, Output, State, ctx, no_update, ALL
def register_drawer_callbacks(app):
"""Register drug/trust selection callbacks (fragment matching + clear)."""
@app.callback(
Output("all-drugs-chips", "value"),
Output("trust-chips", "value"),
Input({"type": "drug-fragment", "index": ALL}, "n_clicks"),
Input("clear-drug-filters", "n_clicks"),
State("all-drugs-chips", "value"),
State("reference-data", "data"),
prevent_initial_call=True,
)
def handle_fragment_or_clear(fragment_clicks, _clear_clicks, current_chips, ref_data):
"""Handle drug fragment badge click (substring match) or clear all filters.
Fragment click: find all full drug names containing the fragment substring,
toggle them in the chip selection. Trust chips unchanged.
Clear click: reset both drug and trust chip selections.
"""
triggered = ctx.triggered_id
# Clear button — reset both drug and trust chips
if triggered == "clear-drug-filters":
return [], []
# Fragment badge click — triggered_id is a dict like {"type": "drug-fragment", "index": "DIR|FRAG"}
if isinstance(triggered, dict) and triggered.get("type") == "drug-fragment":
# Check if any fragment was actually clicked (not just initial render)
if not any(n for n in (fragment_clicks or []) if n):
return no_update, no_update
fragment_key = triggered["index"] # e.g. "CARDIOLOGY|acute coronary syndrome|RIVAROXABAN"
fragment = fragment_key.rsplit("|", 1)[-1] if "|" in fragment_key else fragment_key
# Get all available drugs from reference data
all_drugs = (ref_data or {}).get("available_drugs", [])
# Find drugs whose names contain this fragment (case-insensitive substring)
matching_drugs = [
drug for drug in all_drugs
if fragment.upper() in drug.upper()
]
if not matching_drugs:
return no_update, no_update
# Toggle: if all matching drugs are already selected, deselect them;
# otherwise, add them to selection
current = set(current_chips or [])
all_selected = all(d in current for d in matching_drugs)
if all_selected:
updated = current - set(matching_drugs)
else:
updated = current | set(matching_drugs)
return sorted(updated), no_update
return no_update, no_update
+136
View File
@@ -0,0 +1,136 @@
"""Callbacks for filter selection modals: open/close, fragment matching, clear, count badges."""
from dash import Input, Output, State, ctx, no_update, ALL
def register_modal_callbacks(app):
"""Register all modal-related callbacks."""
# ── Open modals from filter bar buttons ──
@app.callback(
Output("drug-modal", "opened"),
Input("open-drug-modal", "n_clicks"),
prevent_initial_call=True,
)
def open_drug_modal(_n):
return True
@app.callback(
Output("trust-modal", "opened"),
Input("open-trust-modal", "n_clicks"),
prevent_initial_call=True,
)
def open_trust_modal(_n):
return True
@app.callback(
Output("directorate-modal", "opened"),
Input("open-directorate-modal", "n_clicks"),
prevent_initial_call=True,
)
def open_directorate_modal(_n):
return True
# ── Fragment matching + clear ──
@app.callback(
Output("all-drugs-chips", "value"),
Output("trust-chips", "value"),
Input({"type": "drug-fragment", "index": ALL}, "n_clicks"),
Input("clear-drug-filters", "n_clicks"),
Input("clear-drug-selection", "n_clicks"),
Input("clear-trust-selection", "n_clicks"),
Input("clear-all-filters", "n_clicks"),
State("all-drugs-chips", "value"),
State("trust-chips", "value"),
State("reference-data", "data"),
prevent_initial_call=True,
)
def handle_selection_actions(
fragment_clicks, _clear_all_clicks, _clear_drug_clicks,
_clear_trust_clicks, _clear_global_clicks,
current_drugs, current_trusts, ref_data
):
"""Handle fragment clicks, per-modal clears, and global clear."""
triggered = ctx.triggered_id
# Global clear (filter bar) or directorate modal clear
if triggered in ("clear-drug-filters", "clear-all-filters"):
return [], []
# Drug modal clear
if triggered == "clear-drug-selection":
return [], no_update
# Trust modal clear
if triggered == "clear-trust-selection":
return no_update, []
# Fragment badge click
if isinstance(triggered, dict) and triggered.get("type") == "drug-fragment":
if not any(n for n in (fragment_clicks or []) if n):
return no_update, no_update
fragment_key = triggered["index"]
fragment = fragment_key.rsplit("|", 1)[-1] if "|" in fragment_key else fragment_key
all_drugs = (ref_data or {}).get("available_drugs", [])
matching_drugs = [
drug for drug in all_drugs
if fragment.upper() in drug.upper()
]
if not matching_drugs:
return no_update, no_update
current = set(current_drugs or [])
all_selected = all(d in current for d in matching_drugs)
if all_selected:
updated = current - set(matching_drugs)
else:
updated = current | set(matching_drugs)
return sorted(updated), no_update
return no_update, no_update
# ── Count badges in filter bar ──
@app.callback(
Output("drug-count-badge", "children"),
Output("drug-count-badge", "className"),
Output("trust-count-badge", "children"),
Output("trust-count-badge", "className"),
Output("drug-modal-count", "children"),
Output("trust-modal-count", "children"),
Input("all-drugs-chips", "value"),
Input("trust-chips", "value"),
State("reference-data", "data"),
)
def update_count_badges(selected_drugs, selected_trusts, ref_data):
"""Update selection count badges in filter bar and modal headers."""
drug_count = len(selected_drugs or [])
trust_count = len(selected_trusts or [])
all_drugs = (ref_data or {}).get("available_drugs", [])
all_trusts = (ref_data or {}).get("available_trusts", [])
total_drugs = len(all_drugs) if all_drugs else 42
total_trusts = len(all_trusts) if all_trusts else 7
hidden = "filter-btn__badge filter-btn__badge--hidden"
visible = "filter-btn__badge"
drug_badge_text = str(drug_count) if drug_count else ""
drug_badge_class = visible if drug_count else hidden
trust_badge_text = str(trust_count) if trust_count else ""
trust_badge_class = visible if trust_count else hidden
drug_modal_text = f"{drug_count} of {total_drugs} selected"
trust_modal_text = f"{trust_count} of {total_trusts} selected"
return (
drug_badge_text, drug_badge_class,
trust_badge_text, trust_badge_class,
drug_modal_text, trust_modal_text,
)
-206
View File
@@ -1,206 +0,0 @@
"""
Filter drawer component using Dash Mantine Components.
Provides a right-side drawer with:
- "All Drugs" section: flat alphabetical list of all drugs from pathway_nodes
- "Trusts" section: all NHS trusts from pathway_nodes for trust filtering
- Directorate cards: grouped by PrimaryDirectorate from DimSearchTerm.csv,
with Accordion items per Search_Term containing drug fragment chips
- "Clear Filters" button at the bottom
"""
from dash import html
import dash_mantine_components as dmc
from dash_app.data.card_browser import build_directorate_tree, get_all_drugs, get_all_trusts
def _make_drug_chips(drugs: list[str]) -> dmc.ChipGroup:
"""Create a ChipGroup with multiple selection for the 'All Drugs' section."""
return dmc.ChipGroup(
id="all-drugs-chips",
multiple=True,
value=[],
children=[
dmc.Chip(drug, value=drug, size="xs")
for drug in drugs
],
)
def _make_trust_chips(trusts: list[str]) -> dmc.ChipGroup:
"""Create a ChipGroup with multiple selection for the 'Trusts' section."""
return dmc.ChipGroup(
id="trust-chips",
multiple=True,
value=[],
children=[
dmc.Chip(trust, value=trust, size="xs")
for trust in trusts
],
)
def _make_directorate_card(directorate: str, indications: dict[str, list[str]]) -> dmc.AccordionItem:
"""
Create an AccordionItem for a single directorate.
Each indication becomes a panel with drug fragment badges inside.
"""
panels = []
for search_term, fragments in indications.items():
panels.append(
dmc.AccordionItem(
value=f"{directorate}|{search_term}",
children=[
dmc.AccordionControl(
search_term.title(),
className="drawer-indication",
),
dmc.AccordionPanel(
dmc.Group(
gap="xs",
children=[
dmc.Badge(
frag,
id={"type": "drug-fragment", "index": f"{directorate}|{search_term}|{frag}"},
variant="light",
size="sm",
className="drawer-drug-badge",
style={"cursor": "pointer"},
)
for frag in fragments
],
),
),
],
)
)
return dmc.AccordionItem(
value=directorate,
children=[
dmc.AccordionControl(
dmc.Group(
gap="xs",
children=[
dmc.Text(directorate.title(), fw=600, size="sm"),
dmc.Badge(
str(len(indications)),
size="xs",
variant="light",
color="gray",
),
],
),
),
dmc.AccordionPanel(
dmc.Accordion(
variant="separated",
children=panels,
),
),
],
)
def make_drawer():
"""
Build the drug browser drawer component.
Returns a dmc.Drawer that will be opened/closed via callbacks in Phase 4.2.
"""
drugs = get_all_drugs()
trusts = get_all_trusts()
directorate_tree = build_directorate_tree()
# All Drugs section
all_drugs_section = html.Div(
className="drawer-section",
children=[
dmc.Text("All Drugs", fw=700, size="sm", className="drawer-section-title"),
dmc.Text(
f"{len(drugs)} drugs from pathway data",
size="xs",
c="dimmed",
),
html.Div(
className="drawer-chips-wrap",
children=_make_drug_chips(drugs),
),
],
)
# Trusts section
trusts_section = html.Div(
className="drawer-section",
children=[
dmc.Text("Trusts", fw=700, size="sm", className="drawer-section-title"),
dmc.Text(
f"{len(trusts)} NHS trusts",
size="xs",
c="dimmed",
),
html.Div(
className="drawer-chips-wrap",
children=_make_trust_chips(trusts),
),
],
)
# Directorate cards section
directorate_items = [
_make_directorate_card(directorate, indications)
for directorate, indications in directorate_tree.items()
]
directorate_section = html.Div(
className="drawer-section",
children=[
dmc.Text("By Directorate", fw=700, size="sm", className="drawer-section-title"),
dmc.Text(
f"{len(directorate_tree)} directorates \u00b7 {sum(len(v) for v in directorate_tree.values())} indications",
size="xs",
c="dimmed",
),
dmc.Accordion(
variant="separated",
children=directorate_items,
className="drawer-directorate-accordion",
),
],
)
# Clear filters button
clear_button = dmc.Button(
"Clear All Filters",
id="clear-drug-filters",
variant="outline",
color="red",
fullWidth=True,
className="drawer-clear-btn",
)
return dmc.Drawer(
id="drug-drawer",
opened=False,
position="right",
size="480px",
title=dmc.Text("Drug & Indication Browser", fw=700, size="lg"),
children=[
dmc.ScrollArea(
h="calc(100vh - 140px)",
children=dmc.Stack(
gap="md",
children=[
all_drugs_section,
dmc.Divider(),
trusts_section,
dmc.Divider(),
directorate_section,
clear_button,
],
),
),
],
)
+46 -8
View File
@@ -1,16 +1,12 @@
"""Filter bar component — chart type toggle + date filter dropdowns.""" """Filter bar component — chart type toggle, date filters, and modal trigger buttons."""
from dash import html, dcc from dash import html, dcc
def make_filter_bar(): def make_filter_bar():
"""Return a filter bar matching 01_nhs_classic.html structure. """Return a filter bar with chart type toggle, date dropdowns, and filter buttons.
Contains: Filter buttons open modals for drug, trust, and directorate selection.
- Chart type toggle pills (By Directory / By Indication) Each button shows a selection count badge (updated via callbacks).
- Initiated dropdown (All years, Last 2 years, Last 1 year)
- Last seen dropdown (Last 6 months, Last 12 months)
Drug/directorate filters are in the drawer (Phase 4), not here.
""" """
return html.Section( return html.Section(
className="filter-bar", className="filter-bar",
@@ -85,5 +81,47 @@ def make_filter_bar():
), ),
], ],
), ),
# Divider before filter buttons
html.Div(className="filter-bar__divider"),
# Filter trigger buttons
html.Div(
className="filter-bar__group",
children=[
html.Button(
children=[
"Drugs",
html.Span(id="drug-count-badge", className="filter-btn__badge filter-btn__badge--hidden"),
],
id="open-drug-modal",
className="filter-btn",
n_clicks=0,
),
html.Button(
children=[
"Trusts",
html.Span(id="trust-count-badge", className="filter-btn__badge filter-btn__badge--hidden"),
],
id="open-trust-modal",
className="filter-btn",
n_clicks=0,
),
html.Button(
children=[
"Directorates",
html.Span(id="directorate-count-badge", className="filter-btn__badge filter-btn__badge--hidden"),
],
id="open-directorate-modal",
className="filter-btn",
n_clicks=0,
),
],
),
# Clear all filters
html.Button(
"Clear All",
id="clear-all-filters",
className="filter-btn filter-btn--clear",
n_clicks=0,
),
], ],
) )
+248
View File
@@ -0,0 +1,248 @@
"""Filter selection modals using Dash Mantine Components.
Three separate modals replace the single drawer:
- Drug Selection modal: 42 drugs in a ChipGroup with search filter
- Trust Selection modal: 7 trusts in a ChipGroup
- Directorate Browser modal: nested accordion with indication sub-items and drug fragment badges
Component IDs are preserved from the drawer so existing callbacks work unchanged:
- all-drugs-chips, trust-chips, drug-fragment pattern, clear-drug-filters
"""
from dash import html
import dash_mantine_components as dmc
from dash_app.data.card_browser import build_directorate_tree, get_all_drugs, get_all_trusts
def _make_directorate_accordion_item(directorate: str, indications: dict[str, list[str]]) -> dmc.AccordionItem:
"""Create an AccordionItem for a single directorate with nested indication panels."""
panels = []
for search_term, fragments in indications.items():
panels.append(
dmc.AccordionItem(
value=f"{directorate}|{search_term}",
children=[
dmc.AccordionControl(
search_term.title(),
className="modal-indication",
),
dmc.AccordionPanel(
dmc.Group(
gap="xs",
children=[
dmc.Badge(
frag,
id={"type": "drug-fragment", "index": f"{directorate}|{search_term}|{frag}"},
variant="light",
size="sm",
className="modal-drug-badge",
style={"cursor": "pointer"},
)
for frag in fragments
],
),
),
],
)
)
return dmc.AccordionItem(
value=directorate,
children=[
dmc.AccordionControl(
dmc.Group(
gap="xs",
children=[
dmc.Text(directorate.title(), fw=600, size="sm"),
dmc.Badge(
str(len(indications)),
size="xs",
variant="light",
color="gray",
),
],
),
),
dmc.AccordionPanel(
dmc.Accordion(
variant="separated",
children=panels,
),
),
],
)
def make_drug_modal():
"""Build the drug selection modal."""
drugs = get_all_drugs()
return dmc.Modal(
id="drug-modal",
opened=False,
centered=True,
size="lg",
title=dmc.Group(
justify="space-between",
style={"width": "100%"},
children=[
dmc.Text("Select Drugs", fw=600, size="lg"),
dmc.Badge(
id="drug-modal-count",
children=f"0 of {len(drugs)} selected",
variant="light",
color="blue",
),
],
),
overlayProps={"backgroundOpacity": 0.55, "blur": 3},
children=[
html.Div(
className="modal-chips-scroll",
children=[
dmc.Text(
f"{len(drugs)} drugs from pathway data",
size="xs",
c="dimmed",
mb=8,
),
dmc.ChipGroup(
id="all-drugs-chips",
multiple=True,
value=[],
children=[
dmc.Chip(drug, value=drug, size="xs")
for drug in drugs
],
),
],
),
dmc.Space(h=12),
dmc.Group(
justify="flex-end",
children=[
dmc.Button(
"Clear Selection",
id="clear-drug-selection",
variant="subtle",
color="gray",
size="sm",
),
],
),
],
)
def make_trust_modal():
"""Build the trust selection modal."""
trusts = get_all_trusts()
return dmc.Modal(
id="trust-modal",
opened=False,
centered=True,
size="sm",
title=dmc.Group(
justify="space-between",
style={"width": "100%"},
children=[
dmc.Text("Select Trusts", fw=600, size="lg"),
dmc.Badge(
id="trust-modal-count",
children=f"0 of {len(trusts)} selected",
variant="light",
color="blue",
),
],
),
overlayProps={"backgroundOpacity": 0.55, "blur": 3},
children=[
dmc.ChipGroup(
id="trust-chips",
multiple=True,
value=[],
children=[
dmc.Chip(trust, value=trust, size="xs")
for trust in trusts
],
),
dmc.Space(h=12),
dmc.Group(
justify="flex-end",
children=[
dmc.Button(
"Clear Selection",
id="clear-trust-selection",
variant="subtle",
color="gray",
size="sm",
),
],
),
],
)
def make_directorate_modal():
"""Build the directorate browser modal with nested accordion."""
directorate_tree = build_directorate_tree()
directorate_items = [
_make_directorate_accordion_item(directorate, indications)
for directorate, indications in directorate_tree.items()
]
total_indications = sum(len(v) for v in directorate_tree.values())
return dmc.Modal(
id="directorate-modal",
opened=False,
centered=True,
size="xl",
title=dmc.Text("Browse by Directorate", fw=600, size="lg"),
overlayProps={"backgroundOpacity": 0.55, "blur": 3},
children=[
html.Div(
className="modal-chips-scroll",
children=[
dmc.Text(
f"{len(directorate_tree)} directorates \u00b7 {total_indications} indications",
size="xs",
c="dimmed",
mb=8,
),
dmc.Accordion(
variant="separated",
children=directorate_items,
className="modal-directorate-accordion",
),
],
),
dmc.Space(h=12),
dmc.Group(
justify="flex-end",
children=[
dmc.Button(
"Clear All Filters",
id="clear-drug-filters",
variant="subtle",
color="red",
size="sm",
),
],
),
],
)
def make_modals():
"""Return all three filter modals as a list of components."""
return html.Div(
children=[
make_drug_modal(),
make_trust_modal(),
make_directorate_modal(),
],
)