Skip to content

Commit 501497b

Browse files
committed
Add server round-trip drill-down with pagination
1 parent 3dfc5ef commit 501497b

13 files changed

Lines changed: 971 additions & 41 deletions

e2e_playwright/e2e_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@
9797
"test_pivot_per_measure_row_totals",
9898
"test_pivot_per_measure_col_totals",
9999
"test_pivot_col_groups",
100+
"test_pivot_drilldown_pagination",
101+
"test_pivot_drilldown_pagination_hybrid",
100102
]
101103

102104
DATA_PIVOT_KEYS = [

e2e_playwright/pivot_table_interactions_app.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
import pandas as pd
2021
import streamlit as st
2122

2223
from streamlit_pivot import st_pivot_table
@@ -30,6 +31,18 @@
3031
)
3132

3233

34+
def _make_drilldown_pagination_data() -> pd.DataFrame:
35+
"""Generate a dataset where one cell has >500 matching rows to trigger pagination."""
36+
rows = []
37+
for i in range(700):
38+
rows.append({"Region": "Alpha", "Year": "2023", "Revenue": 10 + i})
39+
for i in range(50):
40+
rows.append({"Region": "Alpha", "Year": "2024", "Revenue": 100 + i})
41+
for i in range(30):
42+
rows.append({"Region": "Beta", "Year": "2023", "Revenue": 200 + i})
43+
return pd.DataFrame(rows)
44+
45+
3346
def render_app(data):
3447
df = data["df"]
3548

@@ -216,6 +229,36 @@ def render_app(data):
216229
on_config_change=noop,
217230
)
218231

232+
drill_df = _make_drilldown_pagination_data()
233+
234+
st.subheader("Drilldown Pagination (Client)")
235+
st_pivot_table(
236+
drill_df,
237+
key="test_pivot_drilldown_pagination",
238+
rows=["Region"],
239+
columns=["Year"],
240+
values=["Revenue"],
241+
aggregation="sum",
242+
enable_drilldown=True,
243+
execution_mode="client_only",
244+
interactive=True,
245+
on_config_change=noop,
246+
)
247+
248+
st.subheader("Drilldown Pagination (Hybrid)")
249+
st_pivot_table(
250+
drill_df,
251+
key="test_pivot_drilldown_pagination_hybrid",
252+
rows=["Region"],
253+
columns=["Year"],
254+
values=["Revenue"],
255+
aggregation="sum",
256+
enable_drilldown=True,
257+
execution_mode="threshold_hybrid",
258+
interactive=True,
259+
on_config_change=noop,
260+
)
261+
219262

220263
def main():
221264
init_page()

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,3 +823,81 @@ def test_child_toggle_disabled_when_parent_collapsed(page_at_app: Page):
823823

824824
expect(category_toggle).not_to_have_attribute("role", "button", timeout=5000)
825825
expect(category_toggle).to_have_attribute("title", "Expand Region first")
826+
827+
828+
# ---------------------------------------------------------------------------
829+
# Drilldown Pagination (client-only & hybrid)
830+
# ---------------------------------------------------------------------------
831+
832+
833+
def _open_drilldown_with_pagination(page: Page, pivot_key: str):
834+
"""Click the first data cell (Alpha × 2023 → 700 records) and wait for pagination."""
835+
container = get_pivot(page, pivot_key)
836+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
837+
container.get_by_test_id("pivot-data-cell").first.evaluate("el => el.click()")
838+
panel = page.get_by_test_id("drilldown-panel")
839+
expect(panel).to_be_visible(timeout=10000)
840+
expect(page.get_by_test_id("drilldown-pagination")).to_be_visible(timeout=10000)
841+
return container, panel
842+
843+
844+
def test_drilldown_client_pagination_shows_controls(page_at_app: Page):
845+
"""Client-only: clicking a cell with >500 records shows pagination controls."""
846+
page = page_at_app
847+
_, panel = _open_drilldown_with_pagination(page, "test_pivot_drilldown_pagination")
848+
expect(panel.get_by_test_id("drilldown-prev")).to_be_visible()
849+
expect(panel.get_by_test_id("drilldown-next")).to_be_visible()
850+
expect(panel.locator("text=Page 1 of 2")).to_be_visible()
851+
expect(panel.locator("text=1–500 of 700 records")).to_be_visible()
852+
853+
854+
def test_drilldown_client_pagination_navigates(page_at_app: Page):
855+
"""Client-only: Next navigates to page 2; Prev returns to page 1."""
856+
page = page_at_app
857+
_, panel = _open_drilldown_with_pagination(page, "test_pivot_drilldown_pagination")
858+
859+
expect(panel.get_by_test_id("drilldown-prev")).to_be_disabled()
860+
panel.get_by_test_id("drilldown-next").click()
861+
862+
expect(panel.locator("text=Page 2 of 2")).to_be_visible(timeout=5000)
863+
expect(panel.locator("text=501–700 of 700 records")).to_be_visible()
864+
expect(panel.get_by_test_id("drilldown-next")).to_be_disabled()
865+
866+
panel.get_by_test_id("drilldown-prev").click()
867+
expect(panel.locator("text=Page 1 of 2")).to_be_visible(timeout=5000)
868+
869+
870+
def test_drilldown_hybrid_pagination_shows_controls(page_at_app: Page):
871+
"""Hybrid mode: clicking a cell with >500 records shows pagination controls."""
872+
page = page_at_app
873+
_, panel = _open_drilldown_with_pagination(
874+
page, "test_pivot_drilldown_pagination_hybrid"
875+
)
876+
expect(panel.get_by_test_id("drilldown-prev")).to_be_visible()
877+
expect(panel.get_by_test_id("drilldown-next")).to_be_visible()
878+
expect(panel.locator("text=Page 1 of 2")).to_be_visible()
879+
expect(panel.locator("text=1–500 of 700 records")).to_be_visible()
880+
881+
882+
def test_drilldown_hybrid_pagination_navigates(page_at_app: Page):
883+
"""Hybrid mode: Next navigates to page 2; Prev returns to page 1."""
884+
page = page_at_app
885+
_, panel = _open_drilldown_with_pagination(
886+
page, "test_pivot_drilldown_pagination_hybrid"
887+
)
888+
889+
expect(panel.get_by_test_id("drilldown-prev")).to_be_disabled()
890+
panel.get_by_test_id("drilldown-next").click()
891+
892+
# Hybrid page changes trigger a full Streamlit rerun; re-query the panel
893+
# from the page to avoid stale references and allow extra time.
894+
panel = page.get_by_test_id("drilldown-panel")
895+
expect(panel).to_be_visible(timeout=30000)
896+
expect(panel.locator("text=Page 2 of 2")).to_be_visible(timeout=30000)
897+
expect(panel.locator("text=501–700 of 700 records")).to_be_visible()
898+
expect(panel.get_by_test_id("drilldown-next")).to_be_disabled()
899+
900+
panel.get_by_test_id("drilldown-prev").click()
901+
panel = page.get_by_test_id("drilldown-panel")
902+
expect(panel).to_be_visible(timeout=30000)
903+
expect(panel.locator("text=Page 1 of 2")).to_be_visible(timeout=30000)

streamlit_app.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,105 @@
10791079
language="python",
10801080
)
10811081

1082+
# ---------------------------------------------------------------------------
1083+
# Section 14: Server-Side Drill-Down (Hybrid Mode)
1084+
# ---------------------------------------------------------------------------
1085+
st.divider()
1086+
st.subheader("14. Server-Side Drill-Down (Hybrid Mode)")
1087+
1088+
st.markdown(
1089+
"""
1090+
When datasets are large enough to trigger **threshold_hybrid** mode, the pivot
1091+
data is pre-aggregated on the server before being sent to the browser. In this
1092+
mode, drill-down works via a **server round-trip**: clicking a cell sends a
1093+
request to Python, which filters the *original* un-aggregated DataFrame and
1094+
returns the matching rows.
1095+
1096+
Because large cells can match thousands of rows, the results are **paginated**
1097+
(500 rows per page) with **Prev / Next** controls.
1098+
1099+
**Try it:**
1100+
- Click any data cell — after a brief round-trip the drill-down panel appears.
1101+
- If the cell has more than 500 matching rows, use the **← Prev / Next →**
1102+
buttons at the bottom of the panel to page through all results.
1103+
- The header shows a range like "1–500 of 2,340 records" and the current page.
1104+
1105+
**API parameter used:** `execution_mode` (set to `"threshold_hybrid"` here to
1106+
force hybrid mode on a smaller dataset for demonstration purposes)
1107+
"""
1108+
)
1109+
1110+
import numpy as np # noqa: E402
1111+
1112+
_rng = np.random.default_rng(42)
1113+
_n = 50_000
1114+
df_hybrid = pd.DataFrame(
1115+
{
1116+
"Region": _rng.choice(["North", "South", "East", "West"], _n),
1117+
"Category": _rng.choice(
1118+
["Electronics", "Clothing", "Food", "Furniture", "Toys"], _n
1119+
),
1120+
"Year": _rng.choice([2022, 2023, 2024], _n),
1121+
"Channel": _rng.choice(["Online", "Retail", "Wholesale"], _n),
1122+
"Revenue": _rng.uniform(10, 5000, _n).round(2),
1123+
"Profit": _rng.uniform(-500, 2000, _n).round(2),
1124+
}
1125+
)
1126+
1127+
st_pivot_table(
1128+
df_hybrid,
1129+
key="hybrid_drilldown_demo",
1130+
rows=["Region", "Category"],
1131+
columns=["Year"],
1132+
values=["Revenue", "Profit"],
1133+
aggregation={"Revenue": "sum", "Profit": "sum"},
1134+
number_format={"Revenue": "$,.0f", "Profit": "$,.0f"},
1135+
show_totals=True,
1136+
show_subtotals=True,
1137+
enable_drilldown=True,
1138+
execution_mode="threshold_hybrid",
1139+
)
1140+
1141+
with st.expander("View Code"):
1142+
st.code(
1143+
"""
1144+
import numpy as np
1145+
1146+
rng = np.random.default_rng(42)
1147+
n = 50_000
1148+
df_hybrid = pd.DataFrame({
1149+
"Region": rng.choice(["North", "South", "East", "West"], n),
1150+
"Category": rng.choice(["Electronics", "Clothing", "Food", "Furniture", "Toys"], n),
1151+
"Year": rng.choice([2022, 2023, 2024], n),
1152+
"Channel": rng.choice(["Online", "Retail", "Wholesale"], n),
1153+
"Revenue": rng.uniform(10, 5000, n).round(2),
1154+
"Profit": rng.uniform(-500, 2000, n).round(2),
1155+
})
1156+
1157+
st_pivot_table(
1158+
df_hybrid,
1159+
key="hybrid_drilldown_demo",
1160+
rows=["Region", "Category"],
1161+
columns=["Year"],
1162+
values=["Revenue", "Profit"],
1163+
aggregation={"Revenue": "sum", "Profit": "sum"},
1164+
number_format={"Revenue": "$,.0f", "Profit": "$,.0f"},
1165+
show_totals=True,
1166+
show_subtotals=True,
1167+
enable_drilldown=True,
1168+
execution_mode="threshold_hybrid",
1169+
)
1170+
# Click any cell to see paginated server-side drill-down.
1171+
""",
1172+
language="python",
1173+
)
1174+
1175+
st.caption(
1176+
f"Dataset: {len(df_hybrid):,} rows × {len(df_hybrid.columns)} columns — "
1177+
"forced to threshold_hybrid mode for demonstration."
1178+
)
1179+
1180+
10821181
# ---------------------------------------------------------------------------
10831182
# Footer: Raw Data
10841183
# ---------------------------------------------------------------------------

streamlit_pivot/__init__.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class PivotConfig(TypedDict, total=False):
117117

118118
_warned_keys: set[str] = set()
119119
_PYTHON_CONFIG_STATE_PREFIX = "__streamlit_pivot_python_config__:"
120+
_DRILLDOWN_PAGE_SIZE = 500
120121

121122

122123
def _estimate_group_count(df: Any, fields: list[str]) -> int:
@@ -235,6 +236,33 @@ def _prepare_threshold_hybrid_frame(df: Any, config: PivotConfig) -> Any:
235236
return out
236237

237238

239+
def _compute_hybrid_drilldown(
240+
df: Any,
241+
drilldown_request: dict[str, Any],
242+
page_size: int = _DRILLDOWN_PAGE_SIZE,
243+
) -> tuple[list[dict[str, Any]], list[str], int, int]:
244+
"""Filter the original DataFrame for a hybrid-mode drill-down request.
245+
246+
Returns (records_list, column_names, total_matching_count, page).
247+
"""
248+
filters: dict[str, str] = drilldown_request.get("filters", {})
249+
page: int = max(0, int(drilldown_request.get("page", 0)))
250+
mask = pd.Series(True, index=df.index)
251+
for col, val in filters.items():
252+
if col not in df.columns:
253+
continue
254+
if val == "(null)":
255+
mask &= df[col].isna()
256+
else:
257+
mask &= df[col].astype(str) == str(val)
258+
filtered = df[mask]
259+
total_count = len(filtered)
260+
offset = page * page_size
261+
page_slice = filtered.iloc[offset : offset + page_size]
262+
records = json.loads(page_slice.to_json(orient="records", date_format="iso"))
263+
return records, list(page_slice.columns), total_count, page
264+
265+
238266
def _normalize_aggregation_config(
239267
aggregation: str | dict[str, str] | None,
240268
values: list[str],
@@ -1063,8 +1091,8 @@ def st_pivot_table(
10631091
)
10641092
if use_threshold_hybrid:
10651093
drill_note = (
1066-
" Drill-down is unavailable in this mode because values are "
1067-
"pre-aggregated on the server rather than built from raw rows."
1094+
" Drill-down uses a server round-trip to fetch matching rows "
1095+
"from the original dataset."
10681096
)
10691097
if drill_note not in threshold_reason:
10701098
threshold_reason = f"{threshold_reason}{drill_note}"
@@ -1108,17 +1136,45 @@ def st_pivot_table(
11081136
f"menu_limit must be a positive integer, got {menu_limit!r}"
11091137
)
11101138
data_payload["menu_limit"] = menu_limit
1111-
if not enable_drilldown or use_threshold_hybrid:
1139+
if not enable_drilldown:
11121140
data_payload["enable_drilldown"] = False
11131141
if export_filename is not None:
11141142
data_payload["export_filename"] = export_filename
11151143

1144+
# Server-side drill-down for hybrid mode: read the pending request from
1145+
# session state, filter the *original* (un-aggregated) DataFrame, and
1146+
# ship the matching rows back as JSON records.
1147+
if use_threshold_hybrid and enable_drilldown:
1148+
drilldown_request: dict[str, Any] | None = None
1149+
try:
1150+
state = st.session_state.get(key, {})
1151+
drilldown_request = (
1152+
state.get("drilldown_request") if isinstance(state, dict) else None
1153+
)
1154+
except (AttributeError, TypeError):
1155+
drilldown_request = None
1156+
1157+
if isinstance(drilldown_request, dict) and drilldown_request.get("filters"):
1158+
records, columns, total, page = _compute_hybrid_drilldown(
1159+
data, drilldown_request
1160+
)
1161+
data_payload["drilldown_records"] = records
1162+
data_payload["drilldown_columns"] = columns
1163+
data_payload["drilldown_total_count"] = total
1164+
data_payload["drilldown_page"] = page
1165+
data_payload["drilldown_page_size"] = _DRILLDOWN_PAGE_SIZE
1166+
11161167
mount_kwargs: dict[str, Any] = {
11171168
"key": key,
1118-
"default": {"config": config_to_send, "perf_metrics": None},
1169+
"default": {
1170+
"config": config_to_send,
1171+
"perf_metrics": None,
1172+
"drilldown_request": None,
1173+
},
11191174
"data": data_payload,
11201175
"on_config_change": on_config_change or _noop_callback,
11211176
"on_perf_metrics_change": _noop_callback,
1177+
"on_drilldown_request_change": _noop_callback,
11221178
}
11231179

11241180
if on_cell_click is not None:

0 commit comments

Comments
 (0)