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
@@ -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,
)