From b2d4afd4086436e67a32de60e3b04e75027b41f9 Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Wed, 4 Feb 2026 13:46:57 +0000 Subject: [PATCH] feat: implement filter section with date pickers and searchable dropdowns (Task 2.2) - Add date_range_picker() component with enable/disable checkbox - Add searchable_dropdown() component with search, select all, clear - Implement filter_section() with layout for dates and multi-selects - Add comprehensive state management in AppState: - Filter toggle states (initiated_filter_enabled, last_seen_filter_enabled) - Date values for both ranges - Dropdown visibility state - Selection state for drugs, indications, directorates - Search text state for filtering options - Event handlers for all filter interactions - Computed vars for filtered options and selection counts - Style components using design tokens from styles.py - Debounced handlers deferred to Phase 3.3 (Filter Logic) --- IMPLEMENTATION_PLAN.md | 12 +- pathways_app/app_v2.py | 557 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 550 insertions(+), 19 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a067de1..3fe2f60 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -61,17 +61,17 @@ cd pathways_app && timeout 60 python -m reflex run 2>&1 | head -30 - [x] Verify renders correctly ### 2.2 Filter Section -- [ ] Create `filter_section()` component with card styling -- [ ] Add date range pickers: +- [x] Create `filter_section()` component with card styling +- [x] Add date range pickers: - "Initiated" range with enable/disable checkbox (default: disabled) - "Last Seen" range with enable/disable checkbox (default: enabled, last 6 months) - - "To" date defaults to latest date in dataset -- [ ] Add searchable multi-select dropdowns: + - "To" date defaults to latest date in dataset (placeholder — actual data integration in Phase 3) +- [x] Add searchable multi-select dropdowns: - Drugs dropdown with search, select all, count display - Indications dropdown with search, select all, count display - Directorates dropdown with search, select all, count display -- [ ] Implement debounced filter change handlers (300ms) -- [ ] Style according to design system +- [ ] Implement debounced filter change handlers (300ms) — deferred to Phase 3.3 +- [x] Style according to design system ### 2.3 KPI Row - [ ] Create `kpi_card()` component: diff --git a/pathways_app/app_v2.py b/pathways_app/app_v2.py index 5594789..a6ec43a 100644 --- a/pathways_app/app_v2.py +++ b/pathways_app/app_v2.py @@ -18,7 +18,11 @@ from pathways_app.styles import ( PAGE_MAX_WIDTH, PAGE_PADDING, card_style, + input_style, text_h1, + text_h3, + text_caption, + button_ghost_style, ) @@ -43,11 +47,452 @@ class AppState(rx.State): # Placeholder for current chart type (for top bar tabs) current_chart: str = "icicle" + # Filter toggle state + initiated_filter_enabled: bool = False + last_seen_filter_enabled: bool = True + + # Date filter values (ISO format strings for simplicity) + initiated_from_date: str = "" + initiated_to_date: str = "" + last_seen_from_date: str = "" + last_seen_to_date: str = "" + + # Available options for dropdowns (populated from data in Phase 3) + available_drugs: list[str] = ["Drug A", "Drug B", "Drug C", "Drug D", "Drug E"] + available_indications: list[str] = ["Indication 1", "Indication 2", "Indication 3"] + available_directorates: list[str] = ["Medical", "Surgical", "Oncology", "Rheumatology"] + + # Selected items (empty = all) + selected_drugs: list[str] = [] + selected_indications: list[str] = [] + selected_directorates: list[str] = [] + + # Search text for dropdowns + drug_search: str = "" + indication_search: str = "" + directorate_search: str = "" + + # Dropdown visibility state + drug_dropdown_open: bool = False + indication_dropdown_open: bool = False + directorate_dropdown_open: bool = False + + # Event handlers for filter toggles + def toggle_initiated_filter(self): + """Toggle initiated date filter on/off.""" + self.initiated_filter_enabled = not self.initiated_filter_enabled + + def toggle_last_seen_filter(self): + """Toggle last seen date filter on/off.""" + self.last_seen_filter_enabled = not self.last_seen_filter_enabled + + # Event handlers for date changes + def set_initiated_from(self, value: str): + """Set initiated from date.""" + self.initiated_from_date = value + + def set_initiated_to(self, value: str): + """Set initiated to date.""" + self.initiated_to_date = value + + def set_last_seen_from(self, value: str): + """Set last seen from date.""" + self.last_seen_from_date = value + + def set_last_seen_to(self, value: str): + """Set last seen to date.""" + self.last_seen_to_date = value + + # Event handlers for search + def set_drug_search(self, value: str): + """Update drug search text.""" + self.drug_search = value + + def set_indication_search(self, value: str): + """Update indication search text.""" + self.indication_search = value + + def set_directorate_search(self, value: str): + """Update directorate search text.""" + self.directorate_search = value + + # Event handlers for dropdown visibility + def toggle_drug_dropdown(self): + """Toggle drug dropdown visibility.""" + self.drug_dropdown_open = not self.drug_dropdown_open + # Close other dropdowns + self.indication_dropdown_open = False + self.directorate_dropdown_open = False + + def toggle_indication_dropdown(self): + """Toggle indication dropdown visibility.""" + self.indication_dropdown_open = not self.indication_dropdown_open + # Close other dropdowns + self.drug_dropdown_open = False + self.directorate_dropdown_open = False + + def toggle_directorate_dropdown(self): + """Toggle directorate dropdown visibility.""" + self.directorate_dropdown_open = not self.directorate_dropdown_open + # Close other dropdowns + self.drug_dropdown_open = False + self.indication_dropdown_open = False + + def close_all_dropdowns(self): + """Close all dropdowns.""" + self.drug_dropdown_open = False + self.indication_dropdown_open = False + self.directorate_dropdown_open = False + + # Event handlers for item selection + def toggle_drug(self, drug: str): + """Toggle a drug selection.""" + if drug in self.selected_drugs: + self.selected_drugs = [d for d in self.selected_drugs if d != drug] + else: + self.selected_drugs = self.selected_drugs + [drug] + + def toggle_indication(self, indication: str): + """Toggle an indication selection.""" + if indication in self.selected_indications: + self.selected_indications = [i for i in self.selected_indications if i != indication] + else: + self.selected_indications = self.selected_indications + [indication] + + def toggle_directorate(self, directorate: str): + """Toggle a directorate selection.""" + if directorate in self.selected_directorates: + self.selected_directorates = [d for d in self.selected_directorates if d != directorate] + else: + self.selected_directorates = self.selected_directorates + [directorate] + + # Select/clear all handlers + def select_all_drugs(self): + """Select all available drugs.""" + self.selected_drugs = self.available_drugs.copy() + + def clear_all_drugs(self): + """Clear all drug selections.""" + self.selected_drugs = [] + + def select_all_indications(self): + """Select all available indications.""" + self.selected_indications = self.available_indications.copy() + + def clear_all_indications(self): + """Clear all indication selections.""" + self.selected_indications = [] + + def select_all_directorates(self): + """Select all available directorates.""" + self.selected_directorates = self.available_directorates.copy() + + def clear_all_directorates(self): + """Clear all directorate selections.""" + self.selected_directorates = [] + + # Computed vars for filtered options based on search + @rx.var + def filtered_drugs(self) -> list[str]: + """Return drugs filtered by search text.""" + if not self.drug_search: + return self.available_drugs + search_lower = self.drug_search.lower() + return [d for d in self.available_drugs if search_lower in d.lower()] + + @rx.var + def filtered_indications(self) -> list[str]: + """Return indications filtered by search text.""" + if not self.indication_search: + return self.available_indications + search_lower = self.indication_search.lower() + return [i for i in self.available_indications if search_lower in i.lower()] + + @rx.var + def filtered_directorates(self) -> list[str]: + """Return directorates filtered by search text.""" + if not self.directorate_search: + return self.available_directorates + search_lower = self.directorate_search.lower() + return [d for d in self.available_directorates if search_lower in d.lower()] + + # Computed vars for selection counts + @rx.var + def drug_selection_text(self) -> str: + """Display text for drug selection count.""" + count = len(self.selected_drugs) + total = len(self.available_drugs) + if count == 0: + return f"All {total} drugs" + return f"{count} of {total} selected" + + @rx.var + def indication_selection_text(self) -> str: + """Display text for indication selection count.""" + count = len(self.selected_indications) + total = len(self.available_indications) + if count == 0: + return f"All {total} indications" + return f"{count} of {total} selected" + + @rx.var + def directorate_selection_text(self) -> str: + """Display text for directorate selection count.""" + count = len(self.selected_directorates) + total = len(self.available_directorates) + if count == 0: + return f"All {total} directorates" + return f"{count} of {total} selected" + # ============================================================================= # Layout Components # ============================================================================= +def date_range_picker( + label: str, + enabled: rx.Var[bool], + toggle_handler, + from_value: rx.Var[str], + to_value: rx.Var[str], + on_from_change, + on_to_change, +) -> rx.Component: + """ + Date range picker with enable/disable checkbox. + + Args: + label: Label for the date range (e.g., "Initiated", "Last Seen") + enabled: Whether the filter is active + toggle_handler: Event handler to toggle enabled state + from_value: Current "from" date value + to_value: Current "to" date value + on_from_change: Handler for from date change + on_to_change: Handler for to date change + """ + return rx.vstack( + # Header with checkbox + rx.hstack( + rx.checkbox( + checked=enabled, + on_change=toggle_handler, + size="2", + ), + rx.text( + label, + font_size=Typography.H3_SIZE, + font_weight=Typography.H3_WEIGHT, + color=Colors.SLATE_900, + font_family=Typography.FONT_FAMILY, + ), + align="center", + spacing="2", + ), + # Date inputs + rx.hstack( + rx.vstack( + rx.text( + "From", + **text_caption(), + ), + rx.input( + type="date", + value=from_value, + on_change=on_from_change, + disabled=~enabled, + **input_style(), + width="140px", + opacity=rx.cond(enabled, "1", "0.5"), + ), + spacing="1", + align="start", + ), + rx.vstack( + rx.text( + "To", + **text_caption(), + ), + rx.input( + type="date", + value=to_value, + on_change=on_to_change, + disabled=~enabled, + **input_style(), + width="140px", + opacity=rx.cond(enabled, "1", "0.5"), + ), + spacing="1", + align="start", + ), + spacing="3", + align="end", + ), + spacing="2", + align="start", + ) + + +def searchable_dropdown( + label: str, + selection_text: rx.Var[str], + is_open: rx.Var[bool], + toggle_handler, + search_value: rx.Var[str], + on_search_change, + filtered_items: rx.Var[list[str]], + selected_items: rx.Var[list[str]], + toggle_item_handler, + select_all_handler, + clear_all_handler, +) -> rx.Component: + """ + Searchable multi-select dropdown component. + + Args: + label: Label for the dropdown + selection_text: Text showing selection count + is_open: Whether dropdown is expanded + toggle_handler: Handler to toggle dropdown open/close + search_value: Current search text + on_search_change: Handler for search input change + filtered_items: Items filtered by search + selected_items: Currently selected items + toggle_item_handler: Handler to toggle item selection + select_all_handler: Handler to select all + clear_all_handler: Handler to clear selection + """ + return rx.box( + rx.vstack( + # Label + rx.text( + label, + font_size=Typography.CAPTION_SIZE, + font_weight=Typography.CAPTION_WEIGHT, + color=Colors.SLATE_700, + font_family=Typography.FONT_FAMILY, + ), + # Dropdown trigger button + rx.box( + rx.hstack( + rx.text( + selection_text, + font_size=Typography.BODY_SIZE, + color=Colors.SLATE_900, + font_family=Typography.FONT_FAMILY, + flex="1", + ), + rx.icon( + rx.cond(is_open, "chevron-up", "chevron-down"), + size=16, + color=Colors.SLATE_500, + ), + justify="between", + align="center", + width="100%", + ), + **input_style(), + display="flex", + align_items="center", + cursor="pointer", + on_click=toggle_handler, + width="100%", + ), + # Dropdown panel + rx.cond( + is_open, + rx.box( + rx.vstack( + # Search input + rx.hstack( + rx.icon("search", size=14, color=Colors.SLATE_500), + rx.input( + placeholder="Search...", + value=search_value, + on_change=on_search_change, + variant="soft", + size="2", + width="100%", + ), + spacing="2", + align="center", + width="100%", + padding=Spacing.SM, + background_color=Colors.SLATE_100, + border_radius=Radii.SM, + ), + # Action buttons + rx.hstack( + rx.button( + "Select All", + on_click=select_all_handler, + variant="ghost", + size="1", + color_scheme="blue", + ), + rx.button( + "Clear", + on_click=clear_all_handler, + variant="ghost", + size="1", + color_scheme="gray", + ), + spacing="2", + ), + # Items list + rx.box( + rx.foreach( + filtered_items, + lambda item: rx.box( + rx.checkbox( + item, + checked=selected_items.contains(item), + on_change=lambda: toggle_item_handler(item), + size="2", + ), + padding_y=Spacing.XS, + padding_x=Spacing.SM, + border_radius=Radii.SM, + background_color=rx.cond( + selected_items.contains(item), + Colors.PALE, + "transparent", + ), + _hover={ + "background_color": Colors.SLATE_100, + }, + width="100%", + ), + ), + max_height="200px", + overflow_y="auto", + width="100%", + ), + spacing="2", + align="start", + width="100%", + padding=Spacing.SM, + ), + position="absolute", + top="100%", + left="0", + right="0", + background_color=Colors.WHITE, + border=f"1px solid {Colors.SLATE_300}", + border_radius=Radii.MD, + box_shadow=Shadows.LG, + z_index="50", + margin_top=Spacing.XS, + ), + ), + spacing="1", + align="start", + width="100%", + ), + position="relative", + width="100%", + ) + + def chart_tab(label: str, chart_type: str, is_active: bool = False) -> rx.Component: """ Individual chart type tab/pill for top bar navigation. @@ -174,22 +619,108 @@ def filter_section() -> rx.Component: """ Filter section component. - Contains: Date range pickers, searchable multi-select dropdowns. + Contains: + - Two date range pickers: Initiated (default OFF), Last Seen (default ON) + - Three searchable multi-select dropdowns: Drugs, Indications, Directorates - Will be fully implemented in Task 2.2. + Layout: Two rows + - Row 1: Date pickers side by side + - Row 2: Three dropdowns in a grid """ return rx.box( - rx.text( - "Filters", - **text_h1(), - margin_bottom=Spacing.MD, - ), - rx.text( - "Filter controls will be implemented in Phase 2.", - font_size=Typography.BODY_SIZE, - font_weight=Typography.BODY_WEIGHT, - color=Colors.SLATE_500, - font_family=Typography.FONT_FAMILY, + rx.vstack( + # Header + rx.text( + "Filters", + **text_h1(), + ), + # Row 1: Date range pickers + rx.hstack( + date_range_picker( + label="Initiated", + enabled=AppState.initiated_filter_enabled, + toggle_handler=AppState.toggle_initiated_filter, + from_value=AppState.initiated_from_date, + to_value=AppState.initiated_to_date, + on_from_change=AppState.set_initiated_from, + on_to_change=AppState.set_initiated_to, + ), + rx.divider(orientation="vertical", size="3"), + date_range_picker( + label="Last Seen", + enabled=AppState.last_seen_filter_enabled, + toggle_handler=AppState.toggle_last_seen_filter, + from_value=AppState.last_seen_from_date, + to_value=AppState.last_seen_to_date, + on_from_change=AppState.set_last_seen_from, + on_to_change=AppState.set_last_seen_to, + ), + spacing="5", + align="start", + flex_wrap="wrap", + ), + # Divider + rx.divider(size="4"), + # Row 2: Searchable dropdowns + rx.hstack( + rx.box( + searchable_dropdown( + label="Drugs", + selection_text=AppState.drug_selection_text, + is_open=AppState.drug_dropdown_open, + toggle_handler=AppState.toggle_drug_dropdown, + search_value=AppState.drug_search, + on_search_change=AppState.set_drug_search, + filtered_items=AppState.filtered_drugs, + selected_items=AppState.selected_drugs, + toggle_item_handler=AppState.toggle_drug, + select_all_handler=AppState.select_all_drugs, + clear_all_handler=AppState.clear_all_drugs, + ), + flex="1", + min_width="200px", + ), + rx.box( + searchable_dropdown( + label="Indications", + selection_text=AppState.indication_selection_text, + is_open=AppState.indication_dropdown_open, + toggle_handler=AppState.toggle_indication_dropdown, + search_value=AppState.indication_search, + on_search_change=AppState.set_indication_search, + filtered_items=AppState.filtered_indications, + selected_items=AppState.selected_indications, + toggle_item_handler=AppState.toggle_indication, + select_all_handler=AppState.select_all_indications, + clear_all_handler=AppState.clear_all_indications, + ), + flex="1", + min_width="200px", + ), + rx.box( + searchable_dropdown( + label="Directorates", + selection_text=AppState.directorate_selection_text, + is_open=AppState.directorate_dropdown_open, + toggle_handler=AppState.toggle_directorate_dropdown, + search_value=AppState.directorate_search, + on_search_change=AppState.set_directorate_search, + filtered_items=AppState.filtered_directorates, + selected_items=AppState.selected_directorates, + toggle_item_handler=AppState.toggle_directorate, + select_all_handler=AppState.select_all_directorates, + clear_all_handler=AppState.clear_all_directorates, + ), + flex="1", + min_width="200px", + ), + spacing="4", + width="100%", + flex_wrap="wrap", + ), + spacing="4", + width="100%", + align="start", ), **card_style(), width="100%",