Skip to content

Commit 82a58f2

Browse files
shrutipatel31facebook-github-bot
authored andcommitted
Add toggle_label field to AnalysisCardBase for custom subtitle expansion text (facebook#5124)
Summary: Adds a serializable `toggle_label: str` field to `AnalysisCardBase` that allows analyses to customize the subtitle expand/collapse toggle button text. When non-empty, `toggle_label` replaces the default "See more" text with a context-specific label (e.g., "Expand to see annotated parameters."). The field is persisted to both SQA and JSON storage backends, and used by the notebook HTML template for rendering. Changes: - Add `toggle_label` field + constructor param (default "") to AnalysisCardBase - Update notebook `_to_html()` to use `self.toggle_label or "See more"` - Add `toggle_label` nullable column to SQAAnalysisCard - Update SQA encoder/decoder (all 8 card-type callsites) - Update JSON encoder for both card and group dicts - Unit tests Reviewed By: bernardbeckerman Differential Revision: D98738752
1 parent a415d77 commit 82a58f2

8 files changed

Lines changed: 229 additions & 19 deletions

File tree

ax/core/analysis_card.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from IPython.display import display, HTML, Markdown
1919
from plotly.offline import get_plotlyjs
2020

21+
DEFAULT_SUBTITLE_TOGGLE_LABEL: str = "See more"
22+
2123
# Simple HTML template for rendering a card with a title, subtitle, and body with
2224
# scrollable overflow. Subtitles are rendered in a <div> with max-height overflow
2325
# so that any HTML content (tables, lists, etc.) is valid and clipped correctly
@@ -54,22 +56,24 @@
5456
display: none;
5557
}}
5658
</style>
57-
<div class="card">
59+
<div class="card" id="{card_id}">
5860
<div class="card-header">
5961
<b>{title_str}</b>
6062
<div class="card-subtitle">{subtitle_str}</div>
61-
<button class="card-subtitle-toggle">See more</button>
63+
<button class="card-subtitle-toggle">{toggle_text}</button>
6264
</div>
6365
<div class="card-body">
6466
{body_html}
6567
</div>
6668
</div>
6769
<script>
6870
(function() {{
69-
var card = document.currentScript.previousElementSibling;
71+
var card = document.getElementById('{card_id}');
72+
if (!card) return;
7073
var subtitle = card.querySelector('.card-subtitle');
7174
var toggle = card.querySelector('.card-subtitle-toggle');
7275
if (subtitle && toggle) {{
76+
var originalText = toggle.textContent;
7377
requestAnimationFrame(function() {{
7478
if (subtitle.scrollHeight > subtitle.clientHeight
7579
|| subtitle.scrollWidth > subtitle.clientWidth) {{
@@ -81,7 +85,7 @@
8185
subtitle.style.maxHeight = '1.4em';
8286
subtitle.style.whiteSpace = 'nowrap';
8387
subtitle.style.textOverflow = 'ellipsis';
84-
toggle.textContent = 'See more';
88+
toggle.textContent = originalText;
8589
}} else {{
8690
subtitle.style.maxHeight = 'none';
8791
subtitle.style.whiteSpace = 'normal';
@@ -154,6 +158,7 @@ class AnalysisCardBase(SortableBase, ABC):
154158

155159
title: str
156160
subtitle: str
161+
subtitle_toggle_label: str
157162

158163
_timestamp: datetime
159164

@@ -163,6 +168,7 @@ def __init__(
163168
title: str,
164169
subtitle: str,
165170
timestamp: datetime | None = None,
171+
subtitle_toggle_label: str = DEFAULT_SUBTITLE_TOGGLE_LABEL,
166172
) -> None:
167173
"""
168174
Args:
@@ -175,10 +181,16 @@ def __init__(
175181
timestamp: The time at which the Analysis was computed. This can be
176182
especially useful when querying the database for the most recently
177183
produced artifacts.
184+
subtitle_toggle_label: Custom label for the subtitle
185+
expansion toggle. When non-empty, replaces the default
186+
"See more" text. An empty string is converted to the
187+
default. Persisted to storage and used by both notebook
188+
and web UI rendering.
178189
"""
179190
self.name = name
180191
self.title = title
181192
self.subtitle = subtitle
193+
self.subtitle_toggle_label = subtitle_toggle_label
182194
self._timestamp = timestamp if timestamp is not None else datetime.now()
183195

184196
@abstractmethod
@@ -258,10 +270,16 @@ def _repr_html_(self) -> str:
258270
return plotlyjs_script + self._to_html(depth=0)
259271

260272
def _to_html(self, depth: int) -> str:
273+
toggle_text = self.subtitle_toggle_label or DEFAULT_SUBTITLE_TOGGLE_LABEL
274+
# Use id(self) so each card gets a unique HTML element ID even when
275+
# multiple cards share the same name.
276+
card_id = f"ax-card-{id(self)}"
261277
return html_card_template.format(
278+
card_id=card_id,
262279
title_str=self.title,
263280
subtitle_str=self.subtitle,
264281
body_html=self._body_html(depth=depth),
282+
toggle_text=toggle_text,
265283
)
266284

267285

@@ -282,6 +300,7 @@ def __init__(
282300
subtitle: str | None,
283301
children: Sequence[AnalysisCardBase],
284302
timestamp: datetime | None = None,
303+
subtitle_toggle_label: str = DEFAULT_SUBTITLE_TOGGLE_LABEL,
285304
) -> None:
286305
"""
287306
Args:
@@ -294,12 +313,16 @@ def __init__(
294313
timestamp: The time at which the Analysis was computed. This can be
295314
especially useful when querying the database for the most recently
296315
produced artifacts.
316+
subtitle_toggle_label: Custom label for the subtitle expansion
317+
toggle. When non-empty, replaces the default "See more" text.
318+
An empty string is converted to the default.
297319
"""
298320
super().__init__(
299321
name=name,
300322
title=title,
301323
subtitle=subtitle if subtitle is not None else "",
302324
timestamp=timestamp,
325+
subtitle_toggle_label=subtitle_toggle_label,
303326
)
304327

305328
self.children = [
@@ -404,6 +427,7 @@ def __init__(
404427
df: pd.DataFrame,
405428
blob: str,
406429
timestamp: datetime | None = None,
430+
subtitle_toggle_label: str = DEFAULT_SUBTITLE_TOGGLE_LABEL,
407431
) -> None:
408432
"""
409433
Args:
@@ -417,12 +441,16 @@ def __init__(
417441
timestamp: The time at which the Analysis was computed. This can be
418442
especially useful when querying the database for the most recently
419443
produced artifacts.
444+
subtitle_toggle_label: Custom label for the subtitle expansion
445+
toggle. When non-empty, replaces the default "See more" text.
446+
An empty string is converted to the default.
420447
"""
421448
super().__init__(
422449
name=name,
423450
title=title,
424451
subtitle=subtitle,
425452
timestamp=timestamp,
453+
subtitle_toggle_label=subtitle_toggle_label,
426454
)
427455

428456
self.df = df

ax/core/tests/test_analysis_card.py

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,36 @@
1414
from plotly import graph_objects as go, io as pio
1515

1616

17-
class TestAnalysisCard(TestCase):
18-
def test_hierarchy_str(self) -> None:
19-
test_df = pd.DataFrame(
20-
columns=["a", "b"],
21-
data=[
22-
[1, 2],
23-
[3, 4],
24-
],
25-
)
17+
DUMMY_DF: pd.DataFrame = pd.DataFrame(
18+
columns=["a", "b"],
19+
data=[[1, 2], [3, 4]],
20+
)
21+
2622

27-
base_analysis_card = AnalysisCard(
23+
class TestAnalysisCard(TestCase):
24+
def setUp(self) -> None:
25+
super().setUp()
26+
self.base_card = AnalysisCard(
2827
name="test_base_analysis_card",
2928
title="test_base_analysis_card_title",
3029
subtitle="test_subtitle",
31-
df=test_df,
30+
df=DUMMY_DF,
3231
blob="test blob",
3332
)
33+
34+
def test_hierarchy_str(self) -> None:
3435
markdown_analysis_card = MarkdownAnalysisCard(
3536
name="test_markdown_analysis_card",
3637
title="test_markdown_analysis_card_title",
3738
subtitle="test_subtitle",
38-
df=test_df,
39+
df=DUMMY_DF,
3940
blob="This is some **really cool** markdown",
4041
)
4142
plotly_analysis_card = PlotlyAnalysisCard(
4243
name="test_plotly_analysis_card",
4344
title="test_plotly_analysis_card_title",
4445
subtitle="test_subtitle",
45-
df=test_df,
46+
df=DUMMY_DF,
4647
blob=pio.to_json(go.Figure()),
4748
)
4849

@@ -51,7 +52,7 @@ def test_hierarchy_str(self) -> None:
5152
name="small_group",
5253
title="Small Group",
5354
subtitle="This is a small group with just a few cards",
54-
children=[base_analysis_card, markdown_analysis_card],
55+
children=[self.base_card, markdown_analysis_card],
5556
)
5657
big_group = AnalysisCardGroup(
5758
name="big_group",
@@ -78,3 +79,47 @@ def test_not_applicable_card(self) -> None:
7879
blob="Explanation text.",
7980
)
8081
self.assertIn("Explanation text.", card._body_html(depth=0))
82+
83+
def test_subtitle_toggle_label_rendering(self) -> None:
84+
"""Verify subtitle_toggle_label controls toggle button text in HTML."""
85+
for label, expected_text in (
86+
("", "See more"),
87+
(
88+
"Expand to see annotated parameters.",
89+
"Expand to see annotated parameters.",
90+
),
91+
):
92+
with self.subTest(label=label):
93+
card = AnalysisCard(
94+
name="Test",
95+
title="Title",
96+
subtitle="A long subtitle",
97+
df=pd.DataFrame(),
98+
blob="blob",
99+
subtitle_toggle_label=label,
100+
)
101+
self.assertEqual(card.subtitle_toggle_label, label)
102+
html = card._repr_html_()
103+
self.assertIn(expected_text, html)
104+
105+
def test_analysis_card_group_html_does_not_render_toggle(self) -> None:
106+
"""AnalysisCardGroup._to_html uses html_group_card_template which renders
107+
the subtitle as a plain <p> tag (no collapsible toggle). Verify the group's
108+
own subtitle_toggle_label is stored but not rendered in the group header."""
109+
110+
group = AnalysisCardGroup(
111+
name="G",
112+
title="GT",
113+
subtitle="GS",
114+
children=[self.base_card],
115+
subtitle_toggle_label="Custom toggle.",
116+
)
117+
self.assertEqual(group.subtitle_toggle_label, "Custom toggle.")
118+
119+
html = group._to_html(depth=0)
120+
121+
# The group template uses a plain <p> for subtitles, not the
122+
# collapsible card-subtitle + toggle-button pattern.
123+
self.assertNotIn("Custom toggle.", html)
124+
self.assertIn('<p class="group-subtitle">', html)
125+
self.assertIn("GS", html)

ax/storage/json_store/encoders.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def analysis_card_to_dict(card: AnalysisCard) -> dict[str, Any]:
8989
"df": card.df,
9090
"blob": card.blob,
9191
"timestamp": card._timestamp,
92+
"subtitle_toggle_label": card.subtitle_toggle_label,
9293
}
9394

9495

@@ -101,6 +102,7 @@ def analysis_card_group_to_dict(group: AnalysisCardGroup) -> dict[str, Any]:
101102
"subtitle": group.subtitle,
102103
"children": group.children,
103104
"timestamp": group._timestamp,
105+
"subtitle_toggle_label": group.subtitle_toggle_label,
104106
}
105107

106108

ax/storage/json_store/tests/test_json_store.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@
426426
subtitle="subtitle",
427427
df=pd.DataFrame({"a": [1, 2]}),
428428
blob="blob_str",
429+
subtitle_toggle_label="Expand to see details.",
429430
),
430431
),
431432
(
@@ -486,6 +487,7 @@
486487
subtitle="na subtitle",
487488
df=pd.DataFrame(),
488489
blob="Not enough data.",
490+
subtitle_toggle_label="Expand to see why.",
489491
),
490492
),
491493
(
@@ -510,6 +512,7 @@
510512
blob="# md",
511513
),
512514
],
515+
subtitle_toggle_label="Expand to see children.",
513516
),
514517
),
515518
]
@@ -649,6 +652,64 @@ def test_EncodeDecode(self) -> None:
649652
else:
650653
raise e
651654

655+
def test_EncodeDecodeAnalysisCardSubtitleToggleLabel(self) -> None:
656+
"""Verify decoding old JSON missing subtitle_toggle_label falls back to
657+
the constructor default (backwards compatibility).
658+
"""
659+
660+
# GIVEN old AnalysisCard JSON missing the subtitle_toggle_label key
661+
with self.subTest(msg="backward compatible - AnalysisCard"):
662+
restored = object_from_json(
663+
{
664+
"__type": "AnalysisCard",
665+
"name": "OldCard",
666+
"title": "T",
667+
"subtitle": "S",
668+
"df": {"__type": "DataFrame", "value": "{}"},
669+
"blob": "b",
670+
"timestamp": {
671+
"__type": "datetime",
672+
"value": "2025-01-01 00:00:00.000000",
673+
},
674+
},
675+
decoder_registry=CORE_DECODER_REGISTRY,
676+
class_decoder_registry=CORE_CLASS_DECODER_REGISTRY,
677+
)
678+
self.assertEqual(restored.subtitle_toggle_label, "See more")
679+
680+
# GIVEN old AnalysisCardGroup JSON missing subtitle_toggle_label
681+
with self.subTest(msg="backward compatible - AnalysisCardGroup"):
682+
restored_group = object_from_json(
683+
{
684+
"__type": "AnalysisCardGroup",
685+
"name": "OldGroup",
686+
"title": "GT",
687+
"subtitle": "GS",
688+
"children": [
689+
{
690+
"__type": "AnalysisCard",
691+
"name": "C",
692+
"title": "CT",
693+
"subtitle": "CS",
694+
"df": {"__type": "DataFrame", "value": "{}"},
695+
"blob": "b",
696+
"timestamp": {
697+
"__type": "datetime",
698+
"value": "2025-01-01 00:00:00.000000",
699+
},
700+
}
701+
],
702+
"timestamp": {
703+
"__type": "datetime",
704+
"value": "2025-01-01 00:00:00.000000",
705+
},
706+
},
707+
decoder_registry=CORE_DECODER_REGISTRY,
708+
class_decoder_registry=CORE_CLASS_DECODER_REGISTRY,
709+
)
710+
self.assertEqual(restored_group.subtitle_toggle_label, "")
711+
self.assertEqual(restored_group.children[0].subtitle_toggle_label, "")
712+
652713
def test_EncodeDecode_dataclass_with_initvar(self) -> None:
653714
@dataclasses.dataclass
654715
class TestDataclass:

0 commit comments

Comments
 (0)