Skip to content

Commit 6054477

Browse files
committed
Feat: expand column config cell renderer support
1 parent 64ff875 commit 6054477

22 files changed

Lines changed: 2614 additions & 28 deletions

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Returns a `PivotTableResult` dict containing the current `config` state and opti
8989
| `column_alignment` | `dict[str, str] \| None` | `None` | Per-field text alignment: `"left"`, `"center"`, or `"right"`. |
9090
| `show_values_as` | `dict[str, str] \| None` | `None` | Per-field display mode. See [Show Values As](#show-values-as). |
9191
| `conditional_formatting` | `list[dict] \| None` | `None` | Visual formatting rules. See [Conditional Formatting](#conditional-formatting). |
92-
| `column_config` | `dict[str, Any] \| None` | `None` | Optional per-column display configuration, using a subset of the Streamlit [`column_config`](https://docs.streamlit.io/develop/api-reference/data/st.column_config) shape. Supported keys: `format`, `type`, `label`, `help`, `width` (`"small"` / `"medium"` / `"large"` / integer px), `pinned` (locks the field in the config UI; does not create a sticky column), and `alignment` (`"left"` / `"center"` / `"right"`, unions with the `column_alignment` kwarg; explicit kwarg wins). Explicit `number_format` / `dimension_format` / `column_alignment` parameters always win. See [Formats from `Styler` and `column_config`](#formats-from-styler-and-column_config). |
92+
| `column_config` | `dict[str, Any] \| None` | `None` | Optional per-column display configuration, using a subset of the Streamlit [`column_config`](https://docs.streamlit.io/develop/api-reference/data/st.column_config) shape. Supported keys: `format`, `type`, `label`, `help`, `width` (`"small"` / `"medium"` / `"large"` / integer px), `pinned` (locks the field in the config UI; does not create a sticky column), `alignment` (`"left"` / `"center"` / `"right"`, unions with the `column_alignment` kwarg; explicit kwarg wins), and row-dim cell renderers via `type`: `"link"` (with optional `display_text`), `"image"`, `"checkbox"`, and `"text"` with `max_chars`. Explicit `number_format` / `dimension_format` / `column_alignment` parameters always win. See [Formats from `Styler` and `column_config`](#formats-from-styler-and-column_config). |
9393
| `empty_cell_value` | `str` | `"-"` | Display string for cells with no data. |
9494

9595
#### Layout
@@ -489,6 +489,11 @@ st_pivot_table(
489489
- `width` — either a preset (`"small"`=100px, `"medium"`=120px, `"large"`=200px) or an integer pixel value in the range `[20, 2000]`. Applies to row-dimension columns and measure columns (for the `col-single` header in single-value mode, and per-measure value-label cells in multi-value mode). Out-of-range / unparseable widths warn once per field and are skipped. **Interactive resize drags override the configured width at runtime but are not persisted to config**, so the width returns to the configured value after rerun/remount.
490490
- `pinned` — when `True` or `"left"`, locks the field in the **config UI** (equivalent to adding it to `frozen_columns`): the field cannot be removed from its zone or reordered via drag-and-drop. This does **not** create a visually sticky column. `"right"` is currently warned and ignored.
491491
- `alignment` — one of `"left"`, `"center"`, `"right"`. Unions with the `column_alignment` kwarg; when both set a value for the same field, the explicit `column_alignment` kwarg wins. Invalid values warn once per field and are skipped (unlike the `column_alignment` kwarg, which still raises on invalid values).
492+
- `type` — in addition to the date/time / number role it plays for `format` resolution, a small set of `type` values produce **dimension cell renderers** that apply only to row-dimension cells. Measure cells are always numeric aggregates and ignore these types. On Total / Subtotal rows, `link`, `image`, and `checkbox` fall back to plain text because the cell value is a label rather than data; `text` with `max_chars` still truncates:
493+
- `"link"` — renders the row-dim value as an anchor (`href = <raw value>`). Accepts a `display_text` option (plain string, or a template containing `{}` which is substituted with the cell value, mirroring Streamlit's `LinkColumn` convention). Empty / null values, and values whose scheme isn't on the allowlist (`http:`, `https:`, `mailto:`, `tel:`, plus schemeless relative / protocol-relative URLs), fall back to plain text — hostile `javascript:` / `data:` / `file:` values never reach the DOM.
494+
- `"image"` — renders the row-dim value as an `<img>` (`src = <raw value>`) with `loading="lazy"` and a `max-height` guard so images don't blow out row height. Works in both `row_layout="table"` and `row_layout="hierarchy"` (the hierarchy breadcrumb applies a tighter 1em cap). Only `http:` / `https:` / schemeless URLs and `data:image/<raster-mime>` (png, jpeg, gif, webp, avif, bmp, ico) pass through; everything else — including `data:image/svg+xml` and non-image `data:` MIME types — falls back to plain text.
495+
- `"checkbox"` — renders truthy row-dim values as ☑ and falsy values as ☐. Accepts booleans (`True` / `False`), strings (`"true"` / `"false"` / `"yes"` / `"no"` / `"1"` / `"0"`, case-insensitive), and the numbers `0` / `1`. Unrecognized values fall back to plain text.
496+
- `"text"` with `max_chars` — truncates row-dim cell text to `max_chars` UTF-16 code units (matches JavaScript's native `String.length` / `slice`, which is also what Streamlit's `TextColumn(max_chars=...)` uses) with a trailing ellipsis. The full text is preserved in the cell's `title` attribute for hover inspection. Truncation applies on every row, including Total / Subtotal rows. Invalid `max_chars` values (non-positive, non-integer, or booleans) warn once per field and are skipped.
492497

493498
Unknown keys in dict literals warn once per `(field, key)` pair. Streamlit's internal defaults from typed `st.column_config.*` objects (`disabled`, `required`, `default`) are silently ignored. Recognized but unsupported column types (e.g. `line_chart`, `selectbox`) warn once per `(field, type)`.
494499

@@ -508,6 +513,24 @@ st_pivot_table(
508513
)
509514
```
510515

516+
```python
517+
st_pivot_table(
518+
df,
519+
key="column_config_renderers_example",
520+
rows=["Homepage", "Poster", "Active", "Description"],
521+
values=["Revenue"],
522+
column_config={
523+
"Homepage": st.column_config.LinkColumn(
524+
"Homepage",
525+
display_text="Visit {}",
526+
),
527+
"Poster": st.column_config.ImageColumn("Poster"),
528+
"Active": st.column_config.CheckboxColumn("Active"),
529+
"Description": st.column_config.TextColumn("Description", max_chars=40),
530+
},
531+
)
532+
```
533+
511534
**Precedence.** For format fields: `explicit number_format / dimension_format` > `column_config` > `Styler`. For alignment: `explicit column_alignment` > `column_config.alignment` > default (right-aligned measures, left-aligned dimensions). The lower-priority sources only fill gaps — any field already present in an explicit format or alignment dict keeps the caller-supplied value. `label`, `help`, and `width` are `column_config`-driven only (no legacy kwargs). `pinned` **unions** with `frozen_columns` / `hidden_from_drag_drop`.
512535

513536
### Conditional Formatting

SKILL.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ Creates a pivot table component. All parameters except `data` are keyword-only.
201201
| `column_alignment` | `dict[str, str] \| None` | `None` | `"left"` / `"center"` / `"right"`. |
202202
| `show_values_as` | `dict[str, str] \| None` | `None` | Per-field display mode. See [Show Values As](#show-values-as). |
203203
| `conditional_formatting` | `list[dict] \| None` | `None` | Color scales, data bars, thresholds. |
204-
| `column_config` | `dict[str, Any] \| None` | `None` | Streamlit-style per-column display config. Keys: `format`, `type`, `label`, `help`, `width` (`"small"`/`"medium"`/`"large"`/int px ∈ [20, 2000]), `pinned` (config-UI lock only, not sticky), `alignment` (`"left"`/`"center"`/`"right"`; unions with `column_alignment` kwarg, explicit kwarg wins). Both dict literals and `st.column_config.*` objects are accepted. |
204+
| `column_config` | `dict[str, Any] \| None` | `None` | Streamlit-style per-column display config. Keys: `format`, `type`, `label`, `help`, `width` (`"small"`/`"medium"`/`"large"`/int px ∈ [20, 2000]), `pinned` (config-UI lock only, not sticky), `alignment` (`"left"`/`"center"`/`"right"`; unions with `column_alignment` kwarg, explicit kwarg wins). Row-dim cell renderers via `type`: `"link"` (+ optional `display_text`), `"image"`, `"checkbox"`, `"text"` with `max_chars`. Both dict literals and `st.column_config.*` objects are accepted. |
205205
| `empty_cell_value` | `str` | `"-"` | String for empty cells. |
206206

207207
#### Layout
@@ -401,6 +401,28 @@ st_pivot_table(
401401
- `alignment` accepts `"left"`, `"center"`, or `"right"`. It **unions** with the `column_alignment` kwarg; if both set a value for the same field, the explicit `column_alignment` kwarg wins. Invalid values warn once per field and are skipped (unlike the `column_alignment` kwarg, which still raises on invalid input).
402402
- Unknown keys in dict literals warn once per `(field, key)`; internal defaults on `st.column_config.*` objects (`disabled`, `required`, `default`) are silently ignored; recognized-but-unsupported column types (e.g. `line_chart`, `selectbox`) warn once per `(field, type)`.
403403

404+
**Row-dim cell renderers (Tier 2).** A small set of `type` values change how row-dim cell values render. These apply only to row-dim cells; measure cells are numeric aggregates and ignore them. On Total / Subtotal rows, `link` / `image` / `checkbox` fall back to plain text (cell value is a label, not data); `text` with `max_chars` still truncates.
405+
406+
```python
407+
st_pivot_table(
408+
df,
409+
key="column_config_renderers",
410+
rows=["Homepage", "Poster", "Active", "Description"],
411+
values=["Revenue"],
412+
column_config={
413+
"Homepage": st.column_config.LinkColumn("Homepage", display_text="Visit {}"),
414+
"Poster": st.column_config.ImageColumn("Poster"),
415+
"Active": st.column_config.CheckboxColumn("Active"),
416+
"Description": st.column_config.TextColumn("Description", max_chars=40),
417+
},
418+
)
419+
```
420+
421+
- `type="link"``href = <raw value>`. `display_text` can be a plain string or a `{}` template (substituted with the cell value, matching Streamlit's `LinkColumn`). Empty / null values fall back to plain text. **Scheme allowlist:** only `http:`, `https:`, `mailto:`, `tel:`, and schemeless (relative / protocol-relative) URLs render as anchors; `javascript:`, `data:`, `file:`, `vbscript:`, etc. fall back to plain text.
422+
- `type="image"``src = <raw value>`. Uses `loading="lazy"` and a `max-height` guard so images don't blow out row height. In `row_layout="hierarchy"`, the breadcrumb variant applies a tighter 1em cap. **Src allowlist:** `http:` / `https:` / schemeless URLs plus raster `data:image/<mime>` (png, jpeg, gif, webp, avif, bmp, ico); everything else — notably `data:image/svg+xml` and non-image `data:` — falls back to plain text.
423+
- `type="checkbox"` — truthy → ☑, falsy → ☐. Accepts booleans, `"true"/"false"/"yes"/"no"/"1"/"0"` (case-insensitive), and the numbers `0` / `1`. Unrecognized values fall back to plain text.
424+
- `type="text"` + `max_chars` — truncates to `max_chars` UTF-16 code units (matches JS `String.length` / Streamlit's `TextColumn`) with an ellipsis; full text is preserved in the cell's `title` attribute. Truncation applies on every row, including Totals. Invalid `max_chars` (non-positive, non-integer, bool) warns once and is skipped.
425+
404426
### Conditional Formatting
405427

406428
```python

e2e_playwright/e2e_utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@
156156
"test_pivot_cc_width_px",
157157
"test_pivot_cc_width_preset",
158158
"test_pivot_cc_pinned",
159+
"test_pivot_cc_link",
160+
"test_pivot_cc_image",
161+
"test_pivot_cc_checkbox",
162+
"test_pivot_cc_text_max",
163+
"test_pivot_cc_renderer_totals",
159164
]
160165

161166
APP_CONFIGS = {

e2e_playwright/pivot_table_column_config_app.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,47 @@
1717

1818
from __future__ import annotations
1919

20+
import pandas as pd # type: ignore[import-untyped]
2021
import streamlit as st
2122

2223
from streamlit_pivot import st_pivot_table
2324

2425
from pivot_table_app_support import init_page, load_data, noop
2526

2627

28+
def _renderer_fixture() -> pd.DataFrame:
29+
"""Small fixture exercising Tier 2 cell renderers.
30+
31+
Each row has a URL-like, image-like, boolean, and long-text dim value,
32+
plus a numeric measure so measure cells render through the normal path.
33+
"""
34+
return pd.DataFrame(
35+
{
36+
"Region": ["North", "North", "South", "South"],
37+
"Website": [
38+
"https://example.com/n1",
39+
"https://example.com/n2",
40+
"https://example.com/s1",
41+
"https://example.com/s2",
42+
],
43+
"Logo": [
44+
"https://placehold.co/40x40?text=N1",
45+
"https://placehold.co/40x40?text=N2",
46+
"https://placehold.co/40x40?text=S1",
47+
"https://placehold.co/40x40?text=S2",
48+
],
49+
"Active": [True, False, True, False],
50+
"Note": [
51+
"This is a long note that should be truncated with ellipsis.",
52+
"Another long piece of text that exceeds the configured max.",
53+
"Short",
54+
"Medium length note here.",
55+
],
56+
"Revenue": [100.0, 150.0, 200.0, 250.0],
57+
}
58+
)
59+
60+
2761
def render_app(data):
2862
df = data["df"]
2963

@@ -109,6 +143,90 @@ def render_app(data):
109143
on_config_change=noop,
110144
)
111145

146+
# ------------------------------------------------------------------
147+
# Tier 2 cell renderers (LinkColumn / ImageColumn / CheckboxColumn /
148+
# TextColumn.max_chars). Each pivot uses a dedicated fixture so the
149+
# dim field being exercised is a first-class row dimension.
150+
# ------------------------------------------------------------------
151+
renderer_df = _renderer_fixture()
152+
153+
st.subheader("column_config.type=link")
154+
st_pivot_table(
155+
renderer_df,
156+
key="test_pivot_cc_link",
157+
rows=["Website"],
158+
columns=[],
159+
values=["Revenue"],
160+
aggregation="sum",
161+
column_config={
162+
"Website": {"type": "link", "display_text": "Visit {}"},
163+
},
164+
interactive=True,
165+
on_config_change=noop,
166+
)
167+
168+
st.subheader("column_config.type=image")
169+
st_pivot_table(
170+
renderer_df,
171+
key="test_pivot_cc_image",
172+
rows=["Logo"],
173+
columns=[],
174+
values=["Revenue"],
175+
aggregation="sum",
176+
column_config={
177+
"Logo": {"type": "image"},
178+
},
179+
interactive=True,
180+
on_config_change=noop,
181+
)
182+
183+
st.subheader("column_config.type=checkbox")
184+
st_pivot_table(
185+
renderer_df,
186+
key="test_pivot_cc_checkbox",
187+
rows=["Active"],
188+
columns=[],
189+
values=["Revenue"],
190+
aggregation="sum",
191+
column_config={
192+
"Active": {"type": "checkbox"},
193+
},
194+
interactive=True,
195+
on_config_change=noop,
196+
)
197+
198+
st.subheader("column_config.type=text max_chars")
199+
st_pivot_table(
200+
renderer_df,
201+
key="test_pivot_cc_text_max",
202+
rows=["Note"],
203+
columns=[],
204+
values=["Revenue"],
205+
aggregation="sum",
206+
column_config={
207+
"Note": {"type": "text", "max_chars": 12},
208+
},
209+
interactive=True,
210+
on_config_change=noop,
211+
)
212+
213+
st.subheader("column_config renderers + Subtotal/Total safety")
214+
st_pivot_table(
215+
renderer_df,
216+
key="test_pivot_cc_renderer_totals",
217+
rows=["Region", "Website"],
218+
columns=[],
219+
values=["Revenue"],
220+
aggregation="sum",
221+
show_subtotals=True,
222+
show_totals=True,
223+
column_config={
224+
"Website": {"type": "link", "display_text": "Open"},
225+
},
226+
interactive=True,
227+
on_config_change=noop,
228+
)
229+
112230

113231
def main():
114232
init_page()

e2e_playwright/pivot_table_column_config_test.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,95 @@ def test_column_config_pinned_locks_in_config_ui(page_at_app: Page):
9999
expect(chip).to_be_visible(timeout=5000)
100100
remove_btn = container.get_by_test_id("toolbar-rows-remove-Region")
101101
expect(remove_btn).to_have_count(0)
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Tier 2 cell renderers: LinkColumn / ImageColumn / CheckboxColumn /
106+
# TextColumn.max_chars.
107+
# ---------------------------------------------------------------------------
108+
109+
110+
def test_column_config_link_renderer(page_at_app: Page):
111+
"""column_config={"type": "link"} renders row-dim values as <a> tags
112+
with href=<raw value> and text substituted from display_text='Visit {}'."""
113+
page = page_at_app
114+
container = get_pivot(page, "test_pivot_cc_link")
115+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
116+
117+
anchors = container.get_by_test_id("pivot-link-cell")
118+
expect(anchors.first).to_be_visible(timeout=10000)
119+
# One anchor per distinct Website value in the fixture.
120+
expect(anchors).to_have_count(4)
121+
first = anchors.first
122+
expect(first).to_have_attribute("target", "_blank")
123+
expect(first).to_contain_text("Visit https://example.com/")
124+
125+
126+
def test_column_config_image_renderer(page_at_app: Page):
127+
"""column_config={"type": "image"} renders row-dim values as <img> tags."""
128+
page = page_at_app
129+
container = get_pivot(page, "test_pivot_cc_image")
130+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
131+
132+
imgs = container.get_by_test_id("pivot-image-cell")
133+
expect(imgs.first).to_be_visible(timeout=10000)
134+
expect(imgs).to_have_count(4)
135+
expect(imgs.first).to_have_attribute("loading", "lazy")
136+
137+
138+
def test_column_config_checkbox_renderer(page_at_app: Page):
139+
"""column_config={"type": "checkbox"} renders boolean row-dim values as
140+
☑ / ☐ glyphs with data-checked attributes."""
141+
page = page_at_app
142+
container = get_pivot(page, "test_pivot_cc_checkbox")
143+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
144+
145+
boxes = container.get_by_test_id("pivot-checkbox-cell")
146+
expect(boxes.first).to_be_visible(timeout=10000)
147+
# Two distinct boolean values (True / False) after aggregation.
148+
expect(boxes).to_have_count(2)
149+
checked = container.locator(
150+
'[data-testid="pivot-checkbox-cell"][data-checked="true"]'
151+
)
152+
unchecked = container.locator(
153+
'[data-testid="pivot-checkbox-cell"][data-checked="false"]'
154+
)
155+
expect(checked).to_have_count(1)
156+
expect(unchecked).to_have_count(1)
157+
158+
159+
def test_column_config_text_max_chars(page_at_app: Page):
160+
"""column_config={"type": "text", "max_chars": N} truncates long dim-cell
161+
values with an ellipsis; the full text remains available in the title attr."""
162+
page = page_at_app
163+
container = get_pivot(page, "test_pivot_cc_text_max")
164+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
165+
166+
truncs = container.get_by_test_id("pivot-text-cell-truncated")
167+
expect(truncs.first).to_be_visible(timeout=10000)
168+
# Two rows have long values (>12 chars); one is "Short"; one is
169+
# "Medium length note here." (24 chars, also long).
170+
expect(truncs).to_have_count(3)
171+
first = truncs.first
172+
text = first.inner_text()
173+
assert text.endswith("\u2026"), f"expected ellipsis suffix, got {text!r}"
174+
assert len(text) <= 12, f"expected len<=12, got {len(text)} ({text!r})"
175+
title = first.get_attribute("title") or ""
176+
assert len(title) > 12, f"expected full text in title, got {title!r}"
177+
178+
179+
def test_column_config_link_renderer_subtotal_fallback(page_at_app: Page):
180+
"""Subtotal / Total rows render plain text (no anchor) even when the field
181+
has a link renderer configured."""
182+
page = page_at_app
183+
container = get_pivot(page, "test_pivot_cc_renderer_totals")
184+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
185+
186+
# Subtotal rows have class containing "subtotalHeaderCell"; no link in those.
187+
subtotal_links = container.locator(
188+
'tr:has([class*="subtotalHeaderCell"]) [data-testid="pivot-link-cell"]'
189+
)
190+
expect(subtotal_links).to_have_count(0)
191+
# Data rows should still render anchors (one per distinct Website).
192+
data_anchors = container.get_by_test_id("pivot-link-cell")
193+
expect(data_anchors.first).to_be_visible(timeout=10000)

0 commit comments

Comments
 (0)