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:
+15
-15
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
],
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user