Skip to content

Commit e1727b7

Browse files
committed
Add hierarchical date column headers with expand/collapse
1 parent 4153478 commit e1727b7

22 files changed

Lines changed: 2698 additions & 270 deletions

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ 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.
278279

279280
```python
280281
# Adaptive date hierarchy: grain chosen from the data's date range
@@ -325,6 +326,8 @@ Once a temporal field is active on an axis, open its header menu to:
325326
- switch directly to `Week`,
326327
- choose `Original` to persist a raw-date opt-out for that field.
327328

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.
330+
328331
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.
329332

330333
### Number Format Patterns
@@ -500,7 +503,7 @@ st_pivot_table(
500503
)
501504
```
502505

503-
Hover over a parent column header to reveal the collapse toggle.
506+
Hover over a parent column header to reveal the collapse toggle. Temporal date hierarchies use the same pattern on the column axis, with inline +/- controls on parent date headers.
504507

505508
### Data Export
506509

@@ -512,7 +515,7 @@ Export the pivot table as Excel, CSV, TSV, or copy to clipboard. Available via t
512515

513516
Excel export produces a professionally styled workbook with merged column headers, bold totals/subtotals, number formatting, banded rows, frozen panes (headers stay visible when scrolling), and row dimension merging that matches the rendered table layout. Sort order, active filters, and show-values-as percentages are all preserved. Conditional formatting rules (color scales, data bars, and threshold highlights) are translated to native Excel conditional formatting, so the exported file renders them natively without macros.
514517

515-
Export always outputs the full expanded table regardless of any collapsed row/column groups.
518+
Export always outputs the full expanded table regardless of any collapsed row/column groups, including collapsed temporal date parents.
516519

517520
### Drill-Down Detail Panel
518521

@@ -719,7 +722,7 @@ uv pip install -e '.[with-streamlit]' --force-reinstall
719722
uv run streamlit run streamlit_app.py
720723
```
721724

722-
The example app (`streamlit_app.py`) contains 17 sections covering the major features and usage patterns with interactive examples and inline documentation.
725+
The example app (`streamlit_app.py`) contains 18 sections covering the major features and usage patterns with interactive examples and inline documentation.
723726

724727
### Building the frontend
725728

e2e_playwright/e2e_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"test_pivot_subtotals",
8989
"test_pivot_cond_fmt",
9090
"test_pivot_date_hierarchy",
91+
"test_pivot_date_hierarchy_multidim",
9192
"test_pivot_adaptive_year",
9293
"test_pivot_adaptive_month",
9394
"test_pivot_locked",

e2e_playwright/pivot_table_interactions_app.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,34 @@ def _make_drilldown_pagination_data() -> pd.DataFrame:
4646
def _make_date_hierarchy_data() -> pd.DataFrame:
4747
return pd.DataFrame(
4848
{
49-
"region": ["US", "US", "US", "US", "EU", "EU", "EU", "EU"],
49+
"region": [
50+
"US",
51+
"US",
52+
"US",
53+
"US",
54+
"US",
55+
"EU",
56+
"EU",
57+
"EU",
58+
"EU",
59+
"EU",
60+
],
5061
"order_date": pd.to_datetime(
5162
[
5263
"2024-01-03",
53-
"2024-01-10",
54-
"2024-02-12",
64+
"2024-04-10",
65+
"2024-07-12",
66+
"2024-10-05",
5567
"2025-01-09",
5668
"2024-01-04",
57-
"2024-01-17",
58-
"2024-02-14",
69+
"2024-04-17",
70+
"2024-07-14",
71+
"2024-10-08",
5972
"2025-01-10",
6073
]
6174
),
62-
"revenue": [100, 30, 150, 130, 80, 20, 95, 90],
63-
"profit": [40, 10, 55, 45, 30, 8, 34, 32],
75+
"revenue": [100, 200, 150, 180, 130, 80, 160, 95, 140, 90],
76+
"profit": [40, 80, 55, 65, 45, 30, 60, 34, 50, 32],
6477
}
6578
)
6679

@@ -264,6 +277,18 @@ def render_app(data):
264277
on_config_change=noop,
265278
)
266279

280+
st.subheader("Date Hierarchy Multi-Dim Columns Pivot")
281+
st_pivot_table(
282+
_make_date_hierarchy_data(),
283+
key="test_pivot_date_hierarchy_multidim",
284+
rows=["profit"],
285+
columns=["region", "order_date"],
286+
values=["revenue"],
287+
aggregation="sum",
288+
interactive=True,
289+
on_config_change=noop,
290+
)
291+
267292
# Adaptive date grain: multi-year dataset -> auto-defaults to "year"
268293
st.subheader("Adaptive Grain (Multi-Year)")
269294
adaptive_year_df = pd.DataFrame(

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,140 @@ def test_date_hierarchy_supports_drill_week_and_original(page_at_app: Page):
323323
close_header_menu(page, "header-menu-revenue")
324324

325325

326+
def test_temporal_hierarchy_toggle_collapses_and_expands(page_at_app: Page):
327+
"""Clicking the +/- toggle collapses a year parent and re-expands it."""
328+
page = page_at_app
329+
container = (
330+
page.locator(".st-key-test_pivot_date_hierarchy")
331+
.get_by_test_id("pivot-container")
332+
.first
333+
)
334+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
335+
336+
# With quarter grain, hierarchy is [year, quarter].
337+
# Verify year parent headers are visible.
338+
header_2024 = container.get_by_test_id("pivot-temporal-header-order-date-2024")
339+
expect(header_2024).to_be_visible(timeout=5000)
340+
header_2025 = container.get_by_test_id("pivot-temporal-header-order-date-2025")
341+
expect(header_2025).to_be_visible(timeout=5000)
342+
343+
# Verify leaf quarter headers are visible for 2024.
344+
expect(container.get_by_text("Q1 2024")).to_be_visible(timeout=5000)
345+
expect(container.get_by_text("Q2 2024")).to_be_visible(timeout=5000)
346+
expect(container.get_by_text("Q3 2024")).to_be_visible(timeout=5000)
347+
expect(container.get_by_text("Q4 2024")).to_be_visible(timeout=5000)
348+
349+
# Collapse 2024 by clicking the +/- toggle button.
350+
toggle_2024 = container.get_by_test_id("temporal-toggle-order-date-2024")
351+
toggle_2024.click()
352+
353+
# After collapse: quarter leaf headers under 2024 should be hidden,
354+
# and a collapsed aggregate cell should appear.
355+
expect(container.get_by_text("Q1 2024")).to_be_hidden(timeout=5000)
356+
expect(container.get_by_text("Q2 2024")).to_be_hidden(timeout=5000)
357+
expect(container.get_by_text("Q3 2024")).to_be_hidden(timeout=5000)
358+
expect(container.get_by_text("Q4 2024")).to_be_hidden(timeout=5000)
359+
expect(
360+
container.get_by_test_id("pivot-temporal-collapse-cell").first
361+
).to_be_visible(timeout=5000)
362+
363+
# 2025 quarter should still be visible (not collapsed).
364+
expect(container.get_by_text("Q1 2025")).to_be_visible(timeout=5000)
365+
366+
# Re-expand 2024.
367+
toggle_2024 = container.get_by_test_id("temporal-toggle-order-date-2024")
368+
toggle_2024.click()
369+
370+
# All quarter columns should be visible again.
371+
expect(container.get_by_text("Q1 2024")).to_be_visible(timeout=5000)
372+
expect(container.get_by_text("Q2 2024")).to_be_visible(timeout=5000)
373+
expect(container.get_by_text("Q3 2024")).to_be_visible(timeout=5000)
374+
expect(container.get_by_text("Q4 2024")).to_be_visible(timeout=5000)
375+
expect(container.get_by_test_id("pivot-temporal-collapse-cell")).to_have_count(0)
376+
377+
378+
def test_temporal_hierarchy_collapsed_cells_suppress_comparison(page_at_app: Page):
379+
"""Collapsed parent cells render raw aggregates, not period comparisons."""
380+
page = page_at_app
381+
container = (
382+
page.locator(".st-key-test_pivot_date_hierarchy")
383+
.get_by_test_id("pivot-container")
384+
.first
385+
)
386+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
387+
388+
# The fixture has show_values_as={"revenue": "diff_from_prev"}.
389+
# Leaf cells should have comparison indicators (arrows / deltas).
390+
data_cells = container.get_by_test_id("pivot-data-cell")
391+
expect(data_cells.first).to_be_visible(timeout=5000)
392+
393+
# Collapse 2024.
394+
toggle_2024 = container.get_by_test_id("temporal-toggle-order-date-2024")
395+
toggle_2024.click()
396+
397+
# The collapsed aggregate cells should NOT contain comparison indicators.
398+
collapse_cells = container.get_by_test_id("pivot-temporal-collapse-cell")
399+
expect(collapse_cells.first).to_be_visible(timeout=5000)
400+
401+
# Collapsed cells should contain a plain numeric value (the raw sum for
402+
# all quarters in 2024), not a comparison arrow/delta. A comparison
403+
# indicator includes an arrow character or "▲"/"▼" or "+" prefix.
404+
first_collapse_text = collapse_cells.first.inner_text()
405+
assert (
406+
"▲" not in first_collapse_text
407+
), f"Collapsed cell should not show comparison indicator, got: {first_collapse_text}"
408+
assert (
409+
"▼" not in first_collapse_text
410+
), f"Collapsed cell should not show comparison indicator, got: {first_collapse_text}"
411+
412+
# Re-expand to restore state for other tests.
413+
toggle_2024 = container.get_by_test_id("temporal-toggle-order-date-2024")
414+
toggle_2024.click()
415+
expect(container.get_by_text("Q1 2024")).to_be_visible(timeout=5000)
416+
417+
418+
def test_temporal_hierarchy_multidim_per_instance_collapse(page_at_app: Page):
419+
"""Collapsing one parent instance in a multi-dimension column layout
420+
does not collapse its sibling instance."""
421+
page = page_at_app
422+
container = (
423+
page.locator(".st-key-test_pivot_date_hierarchy_multidim")
424+
.get_by_test_id("pivot-container")
425+
.first
426+
)
427+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
428+
429+
# With columns=["region", "order_date"], the hierarchy has region as an
430+
# outer sibling. Each region gets its own set of year parent headers.
431+
# Verify both EU and US 2024 year headers are visible.
432+
eu_header = container.get_by_test_id("pivot-temporal-header-order-date-2024").first
433+
expect(eu_header).to_be_visible(timeout=5000)
434+
435+
# Collapse the first 2024 instance (should be one region only).
436+
first_toggle = container.get_by_test_id("temporal-toggle-order-date-2024").first
437+
first_toggle.click()
438+
439+
# At least one collapsed cell should appear.
440+
expect(
441+
container.get_by_test_id("pivot-temporal-collapse-cell").first
442+
).to_be_visible(timeout=5000)
443+
444+
# The other region's 2024 quarters should still have visible leaf headers.
445+
# Since only one instance was collapsed, there should still be visible
446+
# quarter headers under the other region's 2024 parent.
447+
second_header = container.get_by_test_id(
448+
"pivot-temporal-header-order-date-2024"
449+
).nth(1)
450+
expect(second_header).to_have_attribute("aria-expanded", "true", timeout=5000)
451+
452+
# Re-expand.
453+
first_toggle = container.get_by_test_id("temporal-toggle-order-date-2024").first
454+
first_toggle.click()
455+
expect(container.get_by_test_id("pivot-temporal-collapse-cell")).to_have_count(
456+
0, timeout=5000
457+
)
458+
459+
326460
def test_drilldown_opens_on_cell_click(page_at_app: Page):
327461
"""Clicking a data cell opens the drilldown panel with a detail table."""
328462
page = page_at_app

streamlit_app.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,9 +1471,14 @@
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.
1477+
14741478
**Try it:**
14751479
- In the first table, notice the adaptive default grain based on the data range.
14761480
- 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.
14771482
- Open the `Revenue` value header menu to switch between raw values and period comparisons.
14781483
- Compare the other tables for explicit override, global opt-out, and per-field opt-out.
14791484
@@ -1523,7 +1528,7 @@
15231528
date_col_3, date_col_4 = st.columns(2)
15241529

15251530
with date_col_3:
1526-
st.caption("Explicit override: Month + MoM comparison")
1531+
st.caption("Explicit override: Month grain (raw sums)")
15271532
st_pivot_table(
15281533
df_dates,
15291534
key="date_hierarchy_quarter",
@@ -1533,7 +1538,6 @@
15331538
aggregation="sum",
15341539
show_totals=True,
15351540
date_grains={"order_date": "month"},
1536-
show_values_as={"Revenue": "diff_from_prev"},
15371541
)
15381542

15391543
with date_col_4:
@@ -1585,14 +1589,13 @@
15851589
values=["Revenue"],
15861590
)
15871591
1588-
# Explicit override: month grain + month-over-month comparison
1592+
# Explicit override: month grain (raw sums)
15891593
st_pivot_table(
15901594
df_dates,
15911595
rows=["region"],
15921596
columns=["order_date"],
15931597
values=["Revenue"],
15941598
date_grains={"order_date": "month"},
1595-
show_values_as={"Revenue": "diff_from_prev"},
15961599
)
15971600
15981601
st_pivot_table(

0 commit comments

Comments
 (0)