From f2c5b2645e9c32238db27051c44bb50ebe46d1e0 Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Fri, 6 Feb 2026 15:42:48 +0000 Subject: [PATCH] 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 --- IMPLEMENTATION_PLAN.md | 30 ++-- dash_app/app.py | 4 +- dash_app/assets/nhs.css | 53 +++++-- dash_app/callbacks/__init__.py | 4 +- dash_app/callbacks/drawer.py | 68 -------- dash_app/callbacks/modals.py | 136 ++++++++++++++++ dash_app/components/drawer.py | 206 ------------------------- dash_app/components/filter_bar.py | 54 ++++++- dash_app/components/modals.py | 248 ++++++++++++++++++++++++++++++ 9 files changed, 488 insertions(+), 315 deletions(-) delete mode 100644 dash_app/callbacks/drawer.py create mode 100644 dash_app/callbacks/modals.py delete mode 100644 dash_app/components/drawer.py create mode 100644 dash_app/components/modals.py diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index b107898..96b1327 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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. ### 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) -- [ ] **Solution**: Replace `dmc.Drawer` with `dmc.Modal` dialogs. Create separate modals: +- [x] **Problem**: The single dmc.Drawer with drugs + trusts + directorates requires excessive scrolling and is confusing (multiple sidebar buttons all open the same drawer) +- [x] **Solution**: Replace `dmc.Drawer` with `dmc.Modal` dialogs. Create separate modals: - Drug Selection modal (contains the All Drugs ChipGroup) - Trust Selection modal (contains the Trust ChipGroup) - 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) -- [ ] 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 -- [ ] 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` -- [ ] **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] Each modal is opened by its corresponding button in the filter bar (see 7.5) +- [x] Modals should be appropriately sized (`size="lg"` or `size="xl"`) and use `dmc.Modal` with `centered=True` +- [x] Preserve all existing selection logic: ChipGroup values, fragment matching, clear button +- [x] Consider having a shared "Clear All Filters" mechanism accessible from each modal or from the filter bar +- [x] Delete `dash_app/components/drawer.py` after modals are working, or refactor it into a `modals.py` +- [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. ### 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. -- [ ] **Add** to the filter bar (alongside existing chart-type toggle and date dropdowns): +- [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. +- [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)") - "Trusts" button that opens the Trust Selection modal (show count badge) - "Directorates" button that opens the Directorate Browser modal (show count badge) - "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 -- [ ] 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] The filter bar should remain static across all chart views (icicle, sankey, timeline) — it's the global filter control +- [x] Update callback wiring: filter bar buttons → open corresponding modal; modal selections → app-state → chart-data → chart +- [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. --- @@ -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] Icicle chart renders with real SQLite data (pathway_nodes) - [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] "All Drugs" card allows selecting any drug across all contexts - [x] "Clear Filters" resets all selections @@ -337,7 +337,7 @@ All tasks marked `[x]` AND: - [x] No Reflex imports in `dash_app/` - [x] No duplicate component ID errors on first load - [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 --- diff --git a/dash_app/app.py b/dash_app/app.py index 107a24e..35eb1fe 100644 --- a/dash_app/app.py +++ b/dash_app/app.py @@ -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=[ diff --git a/dash_app/assets/nhs.css b/dash_app/assets/nhs.css index d6c7e2c..8f13b22 100644 --- a/dash_app/assets/nhs.css +++ b/dash_app/assets/nhs.css @@ -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; } diff --git a/dash_app/callbacks/__init__.py b/dash_app/callbacks/__init__.py index ac156eb..25e4d32 100644 --- a/dash_app/callbacks/__init__.py +++ b/dash_app/callbacks/__init__.py @@ -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) diff --git a/dash_app/callbacks/drawer.py b/dash_app/callbacks/drawer.py deleted file mode 100644 index 49f7b50..0000000 --- a/dash_app/callbacks/drawer.py +++ /dev/null @@ -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 diff --git a/dash_app/callbacks/modals.py b/dash_app/callbacks/modals.py new file mode 100644 index 0000000..6933e58 --- /dev/null +++ b/dash_app/callbacks/modals.py @@ -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, + ) diff --git a/dash_app/components/drawer.py b/dash_app/components/drawer.py deleted file mode 100644 index 3f5ae52..0000000 --- a/dash_app/components/drawer.py +++ /dev/null @@ -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, - ], - ), - ), - ], - ) diff --git a/dash_app/components/filter_bar.py b/dash_app/components/filter_bar.py index 060cc98..05794d1 100644 --- a/dash_app/components/filter_bar.py +++ b/dash_app/components/filter_bar.py @@ -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, + ), ], ) diff --git a/dash_app/components/modals.py b/dash_app/components/modals.py new file mode 100644 index 0000000..e4807a7 --- /dev/null +++ b/dash_app/components/modals.py @@ -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(), + ], + )