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)
This commit is contained in:
@@ -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:
|
||||
|
||||
+540
-9
@@ -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.vstack(
|
||||
# Header
|
||||
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,
|
||||
# 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%",
|
||||
|
||||
Reference in New Issue
Block a user