Skip to content

Commit 2cd2a4d

Browse files
committed
Hierarchy styling, fixes & tests
1 parent 2ebcb18 commit 2cd2a4d

17 files changed

Lines changed: 1567 additions & 343 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ perf_baseline.json
6363

6464
# Local analysis reports
6565
reports/
66+
67+
# Local Streamlit secrets/config
68+
.streamlit/

e2e_playwright/e2e_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@
107107
"test_pivot_date_hierarchy_rows_mixed",
108108
"test_pivot_adaptive_year",
109109
"test_pivot_adaptive_month",
110+
"test_pivot_hierarchy",
111+
"test_pivot_hierarchy_totals",
112+
"test_pivot_hierarchy_locked",
110113
"test_pivot_drilldown_pagination",
111114
"test_pivot_drilldown_pagination_hybrid",
112115
]

e2e_playwright/pivot_table_interactions_app.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,50 @@ def render_app(data):
353353
on_config_change=noop,
354354
)
355355

356+
st.subheader("Hierarchy Layout")
357+
st_pivot_table(
358+
df,
359+
key="test_pivot_hierarchy",
360+
rows=["Region", "Category"],
361+
columns=["Year"],
362+
values=["Revenue"],
363+
aggregation="sum",
364+
row_layout="hierarchy",
365+
show_subtotals=True,
366+
interactive=True,
367+
on_config_change=noop,
368+
)
369+
370+
st.subheader("Hierarchy Layout – Per-Measure Row Totals")
371+
st_pivot_table(
372+
df,
373+
key="test_pivot_hierarchy_totals",
374+
rows=["Region", "Category"],
375+
columns=["Year"],
376+
values=["Revenue", "Profit"],
377+
aggregation="sum",
378+
row_layout="hierarchy",
379+
show_totals=True,
380+
show_row_totals=["Revenue"],
381+
interactive=True,
382+
on_config_change=noop,
383+
)
384+
385+
st.subheader("Hierarchy Layout – Locked")
386+
st_pivot_table(
387+
df,
388+
key="test_pivot_hierarchy_locked",
389+
rows=["Region", "Category"],
390+
columns=["Year"],
391+
values=["Revenue"],
392+
aggregation="sum",
393+
row_layout="hierarchy",
394+
show_subtotals=True,
395+
locked=True,
396+
interactive=True,
397+
on_config_change=noop,
398+
)
399+
356400
drill_df = _make_drilldown_pagination_data()
357401

358402
st.subheader("Drilldown Pagination (Client)")

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 211 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,8 +1197,8 @@ def test_filter_empty_state_and_recovery(page_at_app: Page):
11971197
assert recovered_rows.count() > 0
11981198

11991199

1200-
def test_child_toggle_disabled_when_parent_collapsed(page_at_app: Page):
1201-
"""Collapsing Region disables the Category toggle (no role=button, shows tooltip)."""
1200+
def test_child_toggle_reflects_parent_collapsed_state(page_at_app: Page):
1201+
"""Collapsing Region keeps Category toggle interactive but shows collapsed state."""
12021202
page = page_at_app
12031203
container = get_pivot(page, "test_pivot_dim_toggle")
12041204
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
@@ -1207,13 +1207,220 @@ def test_child_toggle_disabled_when_parent_collapsed(page_at_app: Page):
12071207
category_toggle = container.get_by_test_id("pivot-dim-toggle-row-1-category")
12081208

12091209
expect(category_toggle).to_have_attribute("role", "button")
1210+
expect(category_toggle).to_have_attribute("aria-expanded", "true")
12101211

12111212
region_toggle.click()
12121213
page.wait_for_timeout(1500)
12131214
expect(region_toggle).to_have_attribute("aria-expanded", "false")
12141215

1215-
expect(category_toggle).not_to_have_attribute("role", "button", timeout=5000)
1216-
expect(category_toggle).to_have_attribute("title", "Expand Region first")
1216+
expect(category_toggle).to_have_attribute("role", "button", timeout=5000)
1217+
expect(category_toggle).to_have_attribute("aria-expanded", "false")
1218+
1219+
1220+
# ---------------------------------------------------------------------------
1221+
# Hierarchy Layout
1222+
# ---------------------------------------------------------------------------
1223+
1224+
1225+
def test_hierarchy_layout_renders_breadcrumbs_and_subtotals(page_at_app: Page):
1226+
"""Hierarchy layout renders a single row-header column with breadcrumbs and subtotal rows."""
1227+
page = page_at_app
1228+
container = get_pivot(page, "test_pivot_hierarchy")
1229+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1230+
1231+
breadcrumbs = container.locator("[data-testid^='pivot-row-dim-breadcrumb-']")
1232+
expect(breadcrumbs.first).to_be_visible()
1233+
1234+
subtotals = container.get_by_test_id("pivot-subtotal-row")
1235+
expect(subtotals.first).to_be_visible()
1236+
1237+
data_rows = container.get_by_test_id("pivot-data-row")
1238+
expect(data_rows.first).to_be_visible()
1239+
first_row_headers = data_rows.first.locator("th")
1240+
expect(first_row_headers).to_have_count(1)
1241+
1242+
1243+
def test_hierarchy_layout_breadcrumb_collapse(page_at_app: Page):
1244+
"""Clicking a hierarchy breadcrumb toggle collapses groups at that level."""
1245+
page = page_at_app
1246+
container = get_pivot(page, "test_pivot_hierarchy")
1247+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1248+
1249+
data_rows_before = container.get_by_test_id("pivot-data-row").count()
1250+
1251+
region_toggle = container.get_by_test_id("pivot-dim-toggle-row-0-region")
1252+
expect(region_toggle).to_have_attribute("aria-expanded", "true")
1253+
region_toggle.click()
1254+
page.wait_for_timeout(1500)
1255+
1256+
expect(region_toggle).to_have_attribute("aria-expanded", "false")
1257+
data_rows_after = container.get_by_test_id("pivot-data-row").count()
1258+
assert data_rows_after < data_rows_before
1259+
1260+
1261+
def test_hierarchy_layout_subtotal_group_collapse(page_at_app: Page):
1262+
"""Collapsing a group via its subtotal-row toggle hides child data rows."""
1263+
page = page_at_app
1264+
container = get_pivot(page, "test_pivot_hierarchy")
1265+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1266+
1267+
rows_before = container.get_by_test_id("pivot-data-row").count()
1268+
1269+
toggle = container.locator(
1270+
"[data-testid^='pivot-group-toggle-']"
1271+
":not([data-testid$='-expand-all'])"
1272+
":not([data-testid$='-collapse-all'])"
1273+
).first
1274+
expect(toggle).to_have_attribute("aria-expanded", "true")
1275+
toggle.click()
1276+
expect(toggle).to_have_attribute("aria-expanded", "false", timeout=10000)
1277+
1278+
rows_after = container.get_by_test_id("pivot-data-row").count()
1279+
assert rows_after < rows_before
1280+
expect(container.get_by_test_id("pivot-subtotal-row").first).to_be_visible()
1281+
1282+
1283+
def test_hierarchy_layout_expand_collapse_all(page_at_app: Page):
1284+
"""Collapse All hides child rows; Expand All restores them in hierarchy mode."""
1285+
page = page_at_app
1286+
container = get_pivot(page, "test_pivot_hierarchy")
1287+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1288+
1289+
expand_all = container.get_by_test_id("pivot-group-toggle-expand-all")
1290+
expand_all.scroll_into_view_if_needed()
1291+
expand_all.evaluate("el => el.click()")
1292+
1293+
data_rows = container.get_by_test_id("pivot-data-row")
1294+
rows_expanded = data_rows.count()
1295+
assert rows_expanded > 0
1296+
1297+
collapse_all = container.get_by_test_id("pivot-group-toggle-collapse-all")
1298+
collapse_all.scroll_into_view_if_needed()
1299+
collapse_all.evaluate("el => el.click()")
1300+
1301+
expect(data_rows).not_to_have_count(rows_expanded, timeout=10000)
1302+
rows_collapsed = data_rows.count()
1303+
assert rows_collapsed < rows_expanded
1304+
1305+
expand_all.scroll_into_view_if_needed()
1306+
expand_all.evaluate("el => el.click()")
1307+
expect(data_rows).not_to_have_count(rows_collapsed, timeout=10000)
1308+
1309+
1310+
def test_hierarchy_layout_indented_row_headers(page_at_app: Page):
1311+
"""Data rows in hierarchy mode have a single row header with depth-based indentation."""
1312+
page = page_at_app
1313+
container = get_pivot(page, "test_pivot_hierarchy")
1314+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1315+
1316+
data_rows = container.get_by_test_id("pivot-data-row")
1317+
expect(data_rows.first).to_be_visible(timeout=5000)
1318+
1319+
for row in data_rows.all()[:4]:
1320+
headers = row.locator("th")
1321+
expect(headers).to_have_count(1)
1322+
1323+
first_header = data_rows.first.locator("th")
1324+
padding = first_header.evaluate(
1325+
"el => parseInt(window.getComputedStyle(el.querySelector('span, div') || el).paddingLeft || '0')"
1326+
)
1327+
assert padding >= 0
1328+
1329+
1330+
def test_hierarchy_header_menu_no_subtotal_toggle(page_at_app: Page):
1331+
"""In hierarchy mode, the row-dimension header menu omits the subtotal toggle."""
1332+
page = page_at_app
1333+
container = get_pivot(page, "test_pivot_hierarchy")
1334+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1335+
1336+
trigger = container.locator("[data-testid^='header-menu-trigger-region']").first
1337+
expect(trigger).to_be_visible(timeout=5000)
1338+
1339+
menu = open_header_menu(page, trigger, "header-menu-Region")
1340+
expect(menu.get_by_test_id("header-menu-sort")).to_be_visible()
1341+
expect(menu.get_by_test_id("header-menu-subtotals")).to_have_count(0)
1342+
1343+
close_header_menu(page, "header-menu-Region")
1344+
1345+
1346+
def test_hierarchy_per_measure_row_totals_excluded_shows_dash(page_at_app: Page):
1347+
"""In hierarchy mode, excluded measures in row totals show a dash placeholder."""
1348+
page = page_at_app
1349+
container = get_pivot(page, "test_pivot_hierarchy_totals")
1350+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1351+
1352+
excluded = container.locator('[data-testid="pivot-excluded-total"]')
1353+
expect(excluded.first).to_be_visible()
1354+
expect(excluded.first).to_have_text("–")
1355+
1356+
corner = container.get_by_test_id("pivot-row-dim-label-hierarchy")
1357+
expect(corner).to_be_visible()
1358+
1359+
1360+
def test_settings_switch_table_to_hierarchy_layout(page_at_app: Page):
1361+
"""Switching row layout from Table to Hierarchy via settings panel applies correctly."""
1362+
page = page_at_app
1363+
container = get_pivot(page, "test_pivot_subtotals")
1364+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1365+
1366+
expect(container.get_by_test_id("pivot-row-dim-label-hierarchy")).to_have_count(0)
1367+
1368+
panel = open_settings_panel(page, container)
1369+
hierarchy_btn = panel.get_by_test_id("settings-row-layout-hierarchy")
1370+
expect(hierarchy_btn).to_be_visible()
1371+
hierarchy_btn.click()
1372+
1373+
panel.get_by_test_id("settings-apply").click()
1374+
1375+
expect(container.get_by_test_id("pivot-row-dim-label-hierarchy")).to_be_visible(
1376+
timeout=10000
1377+
)
1378+
1379+
breadcrumbs = container.locator("[data-testid^='pivot-row-dim-breadcrumb-']")
1380+
expect(breadcrumbs.first).to_be_visible(timeout=5000)
1381+
1382+
1383+
def test_settings_hierarchy_forces_subtotals_and_disables_repeat_labels(
1384+
page_at_app: Page,
1385+
):
1386+
"""In hierarchy mode, subtotals checkbox is forced on and repeat labels is disabled."""
1387+
page = page_at_app
1388+
container = get_pivot(page, "test_pivot_subtotals")
1389+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1390+
1391+
panel = open_settings_panel(page, container)
1392+
hierarchy_btn = panel.get_by_test_id("settings-row-layout-hierarchy")
1393+
hierarchy_btn.click()
1394+
1395+
subtotals_input = panel.get_by_test_id("settings-subtotals").locator("input")
1396+
expect(subtotals_input).to_be_checked()
1397+
expect(subtotals_input).to_be_disabled()
1398+
1399+
repeat_input = panel.get_by_test_id("settings-repeat-labels").locator("input")
1400+
expect(repeat_input).to_be_disabled()
1401+
1402+
panel.get_by_test_id("settings-cancel").click()
1403+
1404+
1405+
def test_hierarchy_locked_mode_status_strings(page_at_app: Page):
1406+
"""Locked hierarchy pivot shows correct status labels in the settings panel."""
1407+
page = page_at_app
1408+
container = get_pivot(page, "test_pivot_hierarchy_locked")
1409+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1410+
1411+
panel = open_settings_panel(page, container)
1412+
1413+
expect(panel.get_by_test_id("settings-subtotals-status")).to_contain_text(
1414+
"Always On in Hierarchy"
1415+
)
1416+
expect(panel.get_by_test_id("settings-row-layout-status")).to_contain_text(
1417+
"Hierarchy"
1418+
)
1419+
expect(panel.get_by_test_id("settings-repeat-labels-status")).to_contain_text(
1420+
"N/A in Hierarchy"
1421+
)
1422+
1423+
panel.press("Escape")
12171424

12181425

12191426
# ---------------------------------------------------------------------------

streamlit_pivot/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1818,8 +1818,11 @@ def _default_config(
18181818
cfg["date_grains"] = dict(sorted(date_grains.items()))
18191819
if not sticky_headers:
18201820
cfg["sticky_headers"] = False
1821-
if show_subtotals:
1822-
validated: bool | list[str] = show_subtotals
1821+
effective_subtotals: bool | list[str] = show_subtotals
1822+
if row_layout == "hierarchy" and not show_subtotals:
1823+
effective_subtotals = True
1824+
if effective_subtotals:
1825+
validated: bool | list[str] = effective_subtotals
18231826
if isinstance(validated, list):
18241827
validated = _validate_list_field(
18251828
validated, _rows[:-1], "show_subtotals", "rows"
@@ -1927,8 +1930,8 @@ class PivotTableResult(TypedDict, total=False):
19271930
# See component_manifest_handler.py line 65 for the join logic.
19281931
_component = st.components.v2.component(
19291932
"streamlit-pivot.streamlit_pivot",
1930-
js="index-*.js",
1931-
css="index-*.css",
1933+
js="index.js",
1934+
css="index.css",
19321935
html='<div class="react-root"></div>',
19331936
)
19341937

0 commit comments

Comments
 (0)