Skip to content

Commit 8236042

Browse files
committed
Add drilldown sort
1 parent 7788441 commit 8236042

12 files changed

Lines changed: 893 additions & 23 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,9 @@ result = st_pivot_table(
535535
)
536536
```
537537

538-
- The panel displays up to 500 matching records.
538+
- The panel displays up to 500 matching records per page with pagination controls when there are more.
539+
- **Column sorting:** Click any column header to sort the drilldown results. The sort cycles through ascending, descending, and unsorted (original order). Sorting applies to the **full** result set before pagination, so page boundaries reflect the global sort order.
540+
- In `threshold_hybrid` mode, sorting triggers a server round-trip so the backend sorts the full filtered DataFrame before slicing the requested page.
539541
- Close with the **×** button or by pressing **Escape**.
540542
- Set `enable_drilldown=False` to disable (the `on_cell_click` callback still fires).
541543

@@ -726,7 +728,7 @@ The component follows WAI-ARIA patterns for all interactive elements:
726728
- **Export/Import popovers**: Focus is automatically placed on the first interactive element when opened. Tab/Shift+Tab moves between controls; tabbing out closes the popover.
727729
- **Settings Panel** (pivot icon): Focus moves into the panel on open. Escape closes and discards staged changes. Tab navigates between fields, zones, toggles, and buttons. Aggregation dropdowns support Enter/Space for keyboard selection.
728730
- **Radio groups** (export format/content): Arrow keys move focus between options. Space/Enter selects.
729-
- **Drill-down panel**: Focus moves to the close button on open. Escape closes.
731+
- **Drill-down panel**: Focus moves to the close button on open. Escape closes. Column headers are clickable buttons that cycle sort direction (asc → desc → none).
730732
- **Data cells**: Focusable via Tab. Space/Enter triggers cell click.
731733

732734
---

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,26 @@ def _open_drilldown_with_pagination(page: Page, pivot_key: str):
11371137
return container, panel
11381138

11391139

1140+
def _first_drilldown_revenue_cell(panel):
1141+
"""Return the first visible Revenue cell in the current drilldown page."""
1142+
return (
1143+
panel.get_by_test_id("drilldown-table")
1144+
.locator("tbody tr")
1145+
.first.locator("td")
1146+
.nth(2)
1147+
)
1148+
1149+
1150+
def _drilldown_sort_button(panel, column_name: str):
1151+
"""Return the clickable sort button for a drilldown column header."""
1152+
return (
1153+
panel.get_by_test_id("drilldown-table")
1154+
.locator("th")
1155+
.filter(has_text=column_name)
1156+
.locator("button")
1157+
)
1158+
1159+
11401160
def test_drilldown_client_pagination_shows_controls(page_at_app: Page):
11411161
"""Client-only: clicking a cell with >500 records shows pagination controls."""
11421162
page = page_at_app
@@ -1163,6 +1183,22 @@ def test_drilldown_client_pagination_navigates(page_at_app: Page):
11631183
expect(panel.locator("text=Page 1 of 2")).to_be_visible(timeout=5000)
11641184

11651185

1186+
def test_drilldown_client_sort_orders_full_result_before_pagination(page_at_app: Page):
1187+
"""Client-only drilldown sort reorders the full result set before page slicing."""
1188+
page = page_at_app
1189+
_, panel = _open_drilldown_with_pagination(page, "test_pivot_drilldown_pagination")
1190+
1191+
_drilldown_sort_button(panel, "Revenue").click()
1192+
_drilldown_sort_button(panel, "Revenue").click()
1193+
1194+
# Fixture values for Alpha/2023 are 10..709 inclusive, so descending page 1
1195+
# starts at 709 and descending page 2 starts at 209 after the first 500 rows.
1196+
expect(_first_drilldown_revenue_cell(panel)).to_have_text("709")
1197+
panel.get_by_test_id("drilldown-next").click()
1198+
expect(panel.locator("text=Page 2 of 2")).to_be_visible(timeout=5000)
1199+
expect(_first_drilldown_revenue_cell(panel)).to_have_text("209")
1200+
1201+
11661202
def test_drilldown_hybrid_pagination_shows_controls(page_at_app: Page):
11671203
"""Hybrid mode: clicking a cell with >500 records shows pagination controls."""
11681204
page = page_at_app
@@ -1199,6 +1235,29 @@ def test_drilldown_hybrid_pagination_navigates(page_at_app: Page):
11991235
expect(panel.locator("text=Page 1 of 2")).to_be_visible(timeout=30000)
12001236

12011237

1238+
def test_drilldown_hybrid_sort_orders_full_result_before_pagination(page_at_app: Page):
1239+
"""Hybrid drilldown sort reorders the full result set before server pagination."""
1240+
page = page_at_app
1241+
_, panel = _open_drilldown_with_pagination(
1242+
page, "test_pivot_drilldown_pagination_hybrid"
1243+
)
1244+
1245+
_drilldown_sort_button(panel, "Revenue").click()
1246+
_drilldown_sort_button(panel, "Revenue").click()
1247+
1248+
panel = page.get_by_test_id("drilldown-panel")
1249+
expect(panel).to_be_visible(timeout=30000)
1250+
# Fixture values for Alpha/2023 are 10..709 inclusive, so descending page 1
1251+
# starts at 709 and descending page 2 starts at 209 after the first 500 rows.
1252+
expect(_first_drilldown_revenue_cell(panel)).to_have_text("709", timeout=30000)
1253+
1254+
panel.get_by_test_id("drilldown-next").click()
1255+
panel = page.get_by_test_id("drilldown-panel")
1256+
expect(panel).to_be_visible(timeout=30000)
1257+
expect(panel.locator("text=Page 2 of 2")).to_be_visible(timeout=30000)
1258+
expect(_first_drilldown_revenue_cell(panel)).to_have_text("209", timeout=30000)
1259+
1260+
12021261
# ---------------------------------------------------------------------------
12031262
# Adaptive date grain e2e tests
12041263
# ---------------------------------------------------------------------------

streamlit_app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,9 @@ def section_drilldown():
10071007
- A detail panel slides in below the table showing all matching source records.
10081008
- The header shows the dimension filters (e.g. "Region: East, Year: 2023")
10091009
and the record count.
1010+
- **Click any column header** in the drill-down panel to sort the results.
1011+
Click again to toggle between ascending, descending, and original order.
1012+
Sorting applies to the **full** result set before pagination.
10101013
- Click the **✕** button or press **Escape** to close the panel.
10111014
- Click a different cell to replace the panel with new records.
10121015
@@ -1042,6 +1045,7 @@ def section_drilldown():
10421045
on_cell_click=lambda: None,
10431046
)
10441047
# Click any cell to see the contributing source records.
1048+
# Click a column header in the drill-down panel to sort results.
10451049
""",
10461050
language="python",
10471051
)
@@ -1253,6 +1257,10 @@ def section_hybrid():
12531257
- If the cell has more than 500 matching rows, use the **← Prev / Next →**
12541258
buttons at the bottom of the panel to page through all results.
12551259
- The header shows a range like "1–500 of 2,340 records" and the current page.
1260+
- **Click a column header** to sort the drill-down results. In hybrid mode,
1261+
each sort triggers a server round-trip so the backend sorts the full
1262+
filtered result before slicing the requested page. Navigate to page 2 to
1263+
confirm the sort applies globally, not just within the visible page.
12561264
12571265
**API parameter used:** `execution_mode` (set to `"threshold_hybrid"` here to
12581266
force hybrid mode on a smaller dataset for demonstration purposes)
@@ -1303,6 +1311,7 @@ def section_hybrid():
13031311
execution_mode="threshold_hybrid",
13041312
)
13051313
# Click any cell to see paginated server-side drill-down.
1314+
# Click a column header to sort — sort is applied server-side before pagination.
13061315
""",
13071316
language="python",
13081317
)

streamlit_pivot/__init__.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import warnings
2323
from datetime import date, datetime
2424
from math import prod
25-
from typing import TYPE_CHECKING, Any, TypedDict, cast
25+
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
2626

2727
import pandas as pd
2828

@@ -1434,7 +1434,7 @@ def _append_temporal_row_parent_entries(
14341434

14351435
def _compute_hybrid_drilldown(
14361436
df: Any,
1437-
drilldown_request: dict[str, Any],
1437+
drilldown_request: DrilldownRequest,
14381438
null_handling: Any = None,
14391439
dims: list[str] | None = None,
14401440
config_filters: dict[str, dict] | None = None,
@@ -1469,6 +1469,8 @@ def _compute_hybrid_drilldown(
14691469

14701470
filters: dict[str, str] = drilldown_request.get("filters", {})
14711471
page: int = max(0, int(drilldown_request.get("page", 0)))
1472+
sort_column = drilldown_request.get("sortColumn")
1473+
sort_direction = drilldown_request.get("sortDirection")
14721474

14731475
mask = pd.Series(True, index=working.index)
14741476
for col, val in filters.items():
@@ -1489,6 +1491,47 @@ def _compute_hybrid_drilldown(
14891491
mask &= resolved == str(val)
14901492
filtered = working[mask]
14911493
total_count = len(filtered)
1494+
if (
1495+
sort_column
1496+
and sort_direction in ("asc", "desc")
1497+
and sort_column in filtered.columns
1498+
):
1499+
sort_key = sort_column
1500+
temp_sort_column: str | None = None
1501+
col_type = column_types.get(sort_column) if column_types else None
1502+
if col_type in ("date", "datetime"):
1503+
temp_sort_column = "__drilldown_sort_key__"
1504+
while temp_sort_column in filtered.columns:
1505+
temp_sort_column += "_"
1506+
filtered = filtered.assign(
1507+
**{
1508+
temp_sort_column: pd.to_datetime(
1509+
filtered[sort_column], errors="coerce"
1510+
)
1511+
}
1512+
)
1513+
sort_key = temp_sort_column
1514+
elif col_type in ("integer", "float"):
1515+
temp_sort_column = "__drilldown_sort_key__"
1516+
while temp_sort_column in filtered.columns:
1517+
temp_sort_column += "_"
1518+
filtered = filtered.assign(
1519+
**{
1520+
temp_sort_column: pd.to_numeric(
1521+
filtered[sort_column], errors="coerce"
1522+
)
1523+
}
1524+
)
1525+
sort_key = temp_sort_column
1526+
1527+
filtered = filtered.sort_values(
1528+
by=sort_key,
1529+
ascending=sort_direction == "asc",
1530+
kind="mergesort",
1531+
na_position="last",
1532+
)
1533+
if temp_sort_column is not None:
1534+
filtered = filtered.drop(columns=[temp_sort_column])
14921535
offset = page * page_size
14931536
page_slice = filtered.iloc[offset : offset + page_size]
14941537
records = json.loads(page_slice.to_json(orient="records", date_format="iso"))
@@ -1734,6 +1777,15 @@ class CellClickPayload(TypedDict):
17341777
valueField: str
17351778

17361779

1780+
class DrilldownRequest(CellClickPayload, total=False):
1781+
"""Frontend-owned drilldown request state mirrored through session_state."""
1782+
1783+
page: int
1784+
sortColumn: str
1785+
sortDirection: Literal["asc", "desc"]
1786+
requestId: str
1787+
1788+
17371789
class PerfActionMeasurement(TypedDict, total=False):
17381790
"""Payload fired by setStateValue("perf_metrics", ...)."""
17391791

@@ -2625,6 +2677,7 @@ def st_pivot_table(
26252677
data_payload["drilldown_total_count"] = total
26262678
data_payload["drilldown_page"] = page
26272679
data_payload["drilldown_page_size"] = _DRILLDOWN_PAGE_SIZE
2680+
data_payload["drilldown_request_id"] = drilldown_request.get("requestId")
26282681

26292682
mount_kwargs: dict[str, Any] = {
26302683
"key": key,

0 commit comments

Comments
 (0)