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
+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.chart_card import make_chart_card
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(
__name__,
@@ -34,7 +34,7 @@ app.layout = dmc.MantineProvider(
# Page structure
make_header(),
make_sidebar(),
make_drawer(),
make_modals(),
html.Main(
className="main",
children=[
+39 -14
View File
@@ -259,30 +259,55 @@ body {
.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); }
/* ── Drug Browser Drawer ── */
.drawer-section {
padding: 4px 0;
/* ── Filter Bar Buttons ── */
.filter-btn {
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 {
margin-bottom: 4px;
.filter-btn:hover { background: #F0F4F5; border-color: var(--nhs-blue); }
.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 {
margin-top: 8px;
.filter-btn--clear:hover { color: var(--nhs-red); background: transparent; }
.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;
}
.drawer-drug-badge {
.modal-drug-badge {
cursor: pointer;
transition: background 0.15s;
}
.drawer-drug-badge:hover {
.modal-drug-badge:hover {
filter: brightness(0.9);
}
.drawer-directorate-accordion {
margin-top: 8px;
}
.drawer-clear-btn {
.modal-directorate-accordion {
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.chart import register_chart_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_chart_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
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:
- Chart type toggle pills (By Directory / By Indication)
- 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.
Filter buttons open modals for drug, trust, and directorate selection.
Each button shows a selection count badge (updated via callbacks).
"""
return html.Section(
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(),
],
)