Skip to content

Commit 77e0ec1

Browse files
committed
Add hierarchical date row headers with expand/collapse
1 parent e1727b7 commit 77e0ec1

19 files changed

Lines changed: 2556 additions & 223 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,9 @@ Typed `date` and `datetime` fields are treated as hierarchy-capable dimensions w
275275
- Alternate grouping: `Week` is available from the header menu, but it is not part of the default drill path.
276276
- Explicit override precedence: explicit `date_grains[field]` beats interactive state, which beats the adaptive auto default.
277277
- Explicit opt-out: `date_grains[field] = None` preserves the raw/original date values for that field.
278-
- Hierarchical parent headers with +/- collapse/expand currently render on the **column axis**. Row-side temporal hierarchy parity is planned separately.
278+
- Hierarchical parent groups now render on **both axes**:
279+
- on `columns`, parent headers such as `2024` or `Q1 2024` collapse/expand inline
280+
- on `rows`, collapsing a parent replaces its visible descendants with one synthetic summary row
279281

280282
```python
281283
# Adaptive date hierarchy: grain chosen from the data's date range
@@ -326,7 +328,7 @@ Once a temporal field is active on an axis, open its header menu to:
326328
- switch directly to `Week`,
327329
- choose `Original` to persist a raw-date opt-out for that field.
328330

329-
When a temporal field is on `columns`, parent headers such as `2024` or `Q1 2024` can be collapsed with the inline +/- toggle. This is a view-only collapse: the underlying leaf buckets remain the data/export contract.
331+
When a temporal field is on `columns`, parent headers such as `2024` or `Q1 2024` can be collapsed with the inline +/- toggle. When a temporal field is on `rows`, collapsing a parent replaces the visible child rows with one summary row for that parent. Both are view-only collapses: exports still emit the full expanded leaf-level table.
330332

331333
Grouped buckets export as grouped labels such as `Jan 2024`, `Q1 2024`, or `2024-W03`; they are intentionally not exported as fake raw Excel dates.
332334

e2e_playwright/e2e_utils.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,13 @@
8383
"test_pivot_scalar_roundtrip",
8484
]
8585

86+
# Must match the exact order of st_pivot_table(key=...) calls in
87+
# pivot_table_interactions_app.py so get_pivot()'s nth() fallback matches
88+
# the right component when Streamlit does not emit a stable .st-key-* class.
8689
INTERACTIONS_PIVOT_KEYS = [
8790
"test_pivot",
8891
"test_pivot_subtotals",
8992
"test_pivot_cond_fmt",
90-
"test_pivot_date_hierarchy",
91-
"test_pivot_date_hierarchy_multidim",
92-
"test_pivot_adaptive_year",
93-
"test_pivot_adaptive_month",
9493
"test_pivot_locked",
9594
"test_pivot_locked_groups",
9695
"test_pivot_readonly",
@@ -101,6 +100,12 @@
101100
"test_pivot_per_measure_row_totals",
102101
"test_pivot_per_measure_col_totals",
103102
"test_pivot_col_groups",
103+
"test_pivot_date_hierarchy",
104+
"test_pivot_date_hierarchy_multidim",
105+
"test_pivot_date_hierarchy_rows",
106+
"test_pivot_date_hierarchy_rows_mixed",
107+
"test_pivot_adaptive_year",
108+
"test_pivot_adaptive_month",
104109
"test_pivot_drilldown_pagination",
105110
"test_pivot_drilldown_pagination_hybrid",
106111
]

e2e_playwright/pivot_table_interactions_app.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,30 @@ def render_app(data):
289289
on_config_change=noop,
290290
)
291291

292+
st.subheader("Date Hierarchy Rows Pivot")
293+
st_pivot_table(
294+
_make_date_hierarchy_data(),
295+
key="test_pivot_date_hierarchy_rows",
296+
rows=["order_date"],
297+
columns=["region"],
298+
values=["revenue"],
299+
aggregation="sum",
300+
interactive=True,
301+
on_config_change=noop,
302+
)
303+
304+
st.subheader("Date Hierarchy Mixed Rows Pivot")
305+
st_pivot_table(
306+
_make_date_hierarchy_data(),
307+
key="test_pivot_date_hierarchy_rows_mixed",
308+
rows=["region", "order_date"],
309+
values=["revenue"],
310+
aggregation="sum",
311+
show_subtotals=["region"],
312+
interactive=True,
313+
on_config_change=noop,
314+
)
315+
292316
# Adaptive date grain: multi-year dataset -> auto-defaults to "year"
293317
st.subheader("Adaptive Grain (Multi-Year)")
294318
adaptive_year_df = pd.DataFrame(

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,78 @@ def test_temporal_hierarchy_multidim_per_instance_collapse(page_at_app: Page):
457457
)
458458

459459

460+
def test_row_temporal_hierarchy_toggle_collapses_and_expands(page_at_app: Page):
461+
"""Row-side temporal parents collapse into a single synthetic summary row."""
462+
page = page_at_app
463+
container = (
464+
page.locator(".st-key-test_pivot_date_hierarchy_rows")
465+
.get_by_test_id("pivot-container")
466+
.first
467+
)
468+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
469+
470+
expect(container.locator("thead").get_by_text("Quarter", exact=True)).to_be_visible(
471+
timeout=5000
472+
)
473+
expect(container.get_by_text("Q1 2024")).to_be_visible(timeout=5000)
474+
expect(container.get_by_text("Q4 2024")).to_be_visible(timeout=5000)
475+
476+
toggle_2024 = container.get_by_test_id("pivot-temporal-row-toggle-order_date-2024")
477+
toggle_2024.click()
478+
479+
expect(container.get_by_text("Q1 2024")).to_be_hidden(timeout=5000)
480+
expect(container.get_by_text("Q4 2024")).to_be_hidden(timeout=5000)
481+
expect(container.get_by_test_id("pivot-temporal-parent-row")).to_be_visible(
482+
timeout=5000
483+
)
484+
expect(
485+
container.get_by_test_id("pivot-temporal-row-collapse-cell").first
486+
).to_be_visible(timeout=5000)
487+
488+
toggle_2024 = container.get_by_test_id("pivot-temporal-row-toggle-order_date-2024")
489+
toggle_2024.click()
490+
expect(container.get_by_text("Q1 2024")).to_be_visible(timeout=5000)
491+
expect(container.get_by_text("Q4 2024")).to_be_visible(timeout=5000)
492+
493+
494+
def test_mixed_row_dimension_collapse_preserves_temporal_state(page_at_app: Page):
495+
"""Outer row-group collapse hides temporal parents and restores them on expand."""
496+
page = page_at_app
497+
container = (
498+
page.locator(".st-key-test_pivot_date_hierarchy_rows_mixed")
499+
.get_by_test_id("pivot-container")
500+
.first
501+
)
502+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
503+
504+
us_2024_row = (
505+
container.locator("tr").filter(has_text="US").filter(has_text="Q1 2024")
506+
)
507+
expect(us_2024_row).to_have_count(1, timeout=5000)
508+
us_toggle = us_2024_row.locator(
509+
'[data-testid="pivot-temporal-row-toggle-order_date-2024"]'
510+
)
511+
expect(us_toggle).to_be_visible(timeout=5000)
512+
us_toggle.evaluate(
513+
"el => { el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.click(); }"
514+
)
515+
expect(container.get_by_text("Q1 2024")).to_have_count(1, timeout=10000)
516+
expect(container.get_by_test_id("pivot-temporal-parent-row")).to_have_count(
517+
1, timeout=10000
518+
)
519+
520+
container.get_by_test_id("pivot-group-toggle-US").click()
521+
expect(container.get_by_text("US Total")).to_be_visible(timeout=5000)
522+
expect(container.get_by_test_id("pivot-temporal-parent-row")).to_have_count(
523+
0, timeout=5000
524+
)
525+
526+
container.get_by_test_id("pivot-group-toggle-US").click()
527+
expect(container.get_by_test_id("pivot-temporal-parent-row")).to_have_count(
528+
1, timeout=10000
529+
)
530+
531+
460532
def test_drilldown_opens_on_cell_click(page_at_app: Page):
461533
"""Clicking a data cell opens the drilldown panel with a detail table."""
462534
page = page_at_app
@@ -973,12 +1045,13 @@ def test_hierarchical_sort_preserves_parent_groups(page_at_app: Page):
9731045
expect(region_headers.first).to_be_visible(timeout=5000)
9741046
regions_before = [h.inner_text() for h in region_headers.all()]
9751047

976-
container.get_by_test_id("header-menu-trigger-Category").click()
977-
menu = container.get_by_test_id("header-menu-Category")
978-
expect(menu).to_be_visible(timeout=5000)
979-
980-
menu.get_by_test_id("header-sort-key-asc").click()
981-
page.wait_for_timeout(1500)
1048+
activate_sort_option(
1049+
page,
1050+
container.get_by_test_id("header-menu-trigger-Category"),
1051+
"header-menu-Category",
1052+
"header-sort-key-asc",
1053+
)
1054+
page.wait_for_timeout(500)
9821055

9831056
regions_after = [h.inner_text() for h in region_headers.all()]
9841057
assert regions_before == regions_after, (
@@ -1049,13 +1122,23 @@ def test_child_toggle_disabled_when_parent_collapsed(page_at_app: Page):
10491122

10501123

10511124
def _open_drilldown_with_pagination(page: Page, pivot_key: str):
1052-
"""Click the first data cell (Alpha × 2023 700 records) and wait for pagination."""
1125+
"""Click the Alpha × 2023 data cell (700 raw rows) and wait for pagination."""
10531126
container = get_pivot(page, pivot_key)
10541127
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1055-
container.get_by_test_id("pivot-data-cell").first.evaluate("el => el.click()")
1128+
# Pin the cell by row label so DOM/column order quirks (e.g. WebKit) cannot
1129+
# hit a different bucket than the 700-row Alpha/2023 intersection.
1130+
alpha_row = container.get_by_test_id("pivot-data-row").filter(has_text="Alpha")
1131+
expect(alpha_row).to_have_count(1, timeout=10000)
1132+
cell = alpha_row.get_by_test_id("pivot-data-cell").first
1133+
cell.scroll_into_view_if_needed()
1134+
expect(cell).to_be_visible(timeout=5000)
1135+
cell.evaluate("el => el.click()")
10561136
panel = page.get_by_test_id("drilldown-panel")
10571137
expect(panel).to_be_visible(timeout=10000)
1058-
expect(page.get_by_test_id("drilldown-pagination")).to_be_visible(timeout=10000)
1138+
expect(panel).to_contain_text("700", timeout=15000)
1139+
pagination = page.get_by_test_id("drilldown-pagination")
1140+
pagination.scroll_into_view_if_needed()
1141+
expect(pagination).to_be_visible(timeout=10000)
10591142
return container, panel
10601143

10611144

@@ -1135,6 +1218,8 @@ def test_adaptive_grain_multi_year_defaults_to_year(page_at_app: Page):
11351218
.first
11361219
)
11371220
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1221+
# Year is the auto grain but hierarchy metadata is skipped for grain === "year",
1222+
# so the corner header still uses the combined dimension label.
11381223
header = container.locator("th").filter(has_text="order_date (Year)")
11391224
expect(header).to_be_visible(timeout=10000)
11401225

@@ -1148,5 +1233,7 @@ def test_adaptive_grain_3month_defaults_to_month(page_at_app: Page):
11481233
.first
11491234
)
11501235
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1151-
header = container.locator("th").filter(has_text="order_date (Month)")
1152-
expect(header).to_be_visible(timeout=10000)
1236+
# Month grain uses hierarchy [year, quarter, month] on row headers.
1237+
expect(container.get_by_test_id("pivot-row-dim-label-order-date-2")).to_have_text(
1238+
"Month", timeout=10000
1239+
)

streamlit_app.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,16 +1471,17 @@
14711471
available as an alternate grouping. Period-over-period display modes
14721472
(previous-period, previous-year) are unlocked automatically.
14731473
1474-
For this release, the Excel/Power BI-style parent header collapse/expand UI is
1475-
available on the **column axis**. Exports still keep the full leaf-level date
1476-
columns even when a parent date header is collapsed in the view.
1474+
The Excel/Power BI-style parent collapse/expand UI now works on **both axes**.
1475+
On columns, parent headers collapse into summary columns. On rows, collapsing a
1476+
parent replaces its visible descendants with one synthetic summary row. Exports
1477+
still keep the full leaf-level date structure regardless of collapse state.
14771478
14781479
**Try it:**
14791480
- In the first table, notice the adaptive default grain based on the data range.
14801481
- Open the `order_date` header menu to drill up/down, switch to Week, or choose Original.
1481-
- Use the +/- toggle on a parent date header to collapse a year/quarter group on the column axis.
1482+
- Use the +/- toggle on a parent date group to collapse or expand it on either axis.
14821483
- Open the `Revenue` value header menu to switch between raw values and period comparisons.
1483-
- Compare the other tables for explicit override, global opt-out, and per-field opt-out.
1484+
- Compare the other tables for explicit override, global opt-out, per-field opt-out, and the row-side collapse example in the bottom-right.
14841485
14851486
**API parameters used:** `auto_date_hierarchy`, `date_grains`, `show_values_as`
14861487
"""
@@ -1553,7 +1554,7 @@
15531554
auto_date_hierarchy=False,
15541555
)
15551556

1556-
date_col_5, _ = st.columns(2)
1557+
date_col_5, date_col_6 = st.columns(2)
15571558

15581559
with date_col_5:
15591560
st.caption("Per-field Original opt-out")
@@ -1568,6 +1569,20 @@
15681569
date_grains={"ship_date": None},
15691570
)
15701571

1572+
with date_col_6:
1573+
st.caption(
1574+
"Row-side hierarchy with collapsible parent rows (click the +/- in the row headers)"
1575+
)
1576+
st_pivot_table(
1577+
df_dates,
1578+
key="date_hierarchy_rows",
1579+
rows=["order_date"],
1580+
columns=["region"],
1581+
values=["Revenue"],
1582+
aggregation="sum",
1583+
show_totals=True,
1584+
)
1585+
15711586
with st.expander("View Code"):
15721587
st.code(
15731588
"""
@@ -1615,6 +1630,14 @@
16151630
values=["Revenue"],
16161631
date_grains={"ship_date": None},
16171632
)
1633+
1634+
st_pivot_table(
1635+
df_dates,
1636+
key="date_hierarchy_rows",
1637+
rows=["order_date"],
1638+
columns=["region"],
1639+
values=["Revenue"],
1640+
)
16181641
""",
16191642
language="python",
16201643
)

0 commit comments

Comments
 (0)