Skip to content

Commit 4feedc6

Browse files
shrutipatel31meta-codesync[bot]
authored andcommitted
Add toggle_label field to AnalysisCardBase for custom subtitle expansion text (#5124)
Summary: Pull Request resolved: #5124 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 Differential Revision: D98738752
1 parent f3e8831 commit 4feedc6

8 files changed

Lines changed: 223 additions & 19 deletions

File tree

ax/core/analysis_card.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,24 @@
5454
display: none;
5555
}}
5656
</style>
57-
<div class="card">
57+
<div class="card" id="{card_id}">
5858
<div class="card-header">
5959
<b>{title_str}</b>
6060
<div class="card-subtitle">{subtitle_str}</div>
61-
<button class="card-subtitle-toggle">See more</button>
61+
<button class="card-subtitle-toggle">{toggle_text}</button>
6262
</div>
6363
<div class="card-body">
6464
{body_html}
6565
</div>
6666
</div>
6767
<script>
6868
(function() {{
69-
var card = document.currentScript.previousElementSibling;
69+
var card = document.getElementById('{card_id}');
70+
if (!card) return;
7071
var subtitle = card.querySelector('.card-subtitle');
7172
var toggle = card.querySelector('.card-subtitle-toggle');
7273
if (subtitle && toggle) {{
74+
var originalText = toggle.textContent;
7375
requestAnimationFrame(function() {{
7476
if (subtitle.scrollHeight > subtitle.clientHeight
7577
|| subtitle.scrollWidth > subtitle.clientWidth) {{
@@ -81,7 +83,7 @@
8183
subtitle.style.maxHeight = '1.4em';
8284
subtitle.style.whiteSpace = 'nowrap';
8385
subtitle.style.textOverflow = 'ellipsis';
84-
toggle.textContent = 'See more';
86+
toggle.textContent = originalText;
8587
}} else {{
8688
subtitle.style.maxHeight = 'none';
8789
subtitle.style.whiteSpace = 'normal';
@@ -154,6 +156,7 @@ class AnalysisCardBase(SortableBase, ABC):
154156

155157
title: str
156158
subtitle: str
159+
subtitle_toggle_label: str
157160

158161
_timestamp: datetime
159162

@@ -163,6 +166,7 @@ def __init__(
163166
title: str,
164167
subtitle: str,
165168
timestamp: datetime | None = None,
169+
subtitle_toggle_label: str = "",
166170
) -> None:
167171
"""
168172
Args:
@@ -175,10 +179,15 @@ def __init__(
175179
timestamp: The time at which the Analysis was computed. This can be
176180
especially useful when querying the database for the most recently
177181
produced artifacts.
182+
subtitle_toggle_label: Custom label for the subtitle
183+
expansion toggle. When non-empty, replaces the default
184+
"See more" text. Persisted to storage and used by both
185+
notebook and web UI rendering.
178186
"""
179187
self.name = name
180188
self.title = title
181189
self.subtitle = subtitle
190+
self.subtitle_toggle_label = subtitle_toggle_label
182191
self._timestamp = timestamp if timestamp is not None else datetime.now()
183192

184193
@abstractmethod
@@ -258,10 +267,16 @@ def _repr_html_(self) -> str:
258267
return plotlyjs_script + self._to_html(depth=0)
259268

260269
def _to_html(self, depth: int) -> str:
270+
toggle_text = self.subtitle_toggle_label or "See more"
271+
# Use id(self) so each card gets a unique HTML element ID even when
272+
# multiple cards share the same name.
273+
card_id = f"ax-card-{id(self)}"
261274
return html_card_template.format(
275+
card_id=card_id,
262276
title_str=self.title,
263277
subtitle_str=self.subtitle,
264278
body_html=self._body_html(depth=depth),
279+
toggle_text=toggle_text,
265280
)
266281

267282

@@ -282,6 +297,7 @@ def __init__(
282297
subtitle: str | None,
283298
children: Sequence[AnalysisCardBase],
284299
timestamp: datetime | None = None,
300+
subtitle_toggle_label: str = "",
285301
) -> None:
286302
"""
287303
Args:
@@ -294,12 +310,15 @@ def __init__(
294310
timestamp: The time at which the Analysis was computed. This can be
295311
especially useful when querying the database for the most recently
296312
produced artifacts.
313+
subtitle_toggle_label: Custom label for the subtitle expansion
314+
toggle. When non-empty, replaces the default "See more" text
297315
"""
298316
super().__init__(
299317
name=name,
300318
title=title,
301319
subtitle=subtitle if subtitle is not None else "",
302320
timestamp=timestamp,
321+
subtitle_toggle_label=subtitle_toggle_label,
303322
)
304323

305324
self.children = [
@@ -404,6 +423,7 @@ def __init__(
404423
df: pd.DataFrame,
405424
blob: str,
406425
timestamp: datetime | None = None,
426+
subtitle_toggle_label: str = "",
407427
) -> None:
408428
"""
409429
Args:
@@ -417,12 +437,15 @@ def __init__(
417437
timestamp: The time at which the Analysis was computed. This can be
418438
especially useful when querying the database for the most recently
419439
produced artifacts.
440+
subtitle_toggle_label: Custom label for the subtitle expansion
441+
toggle. When non-empty, replaces the default "See more" text
420442
"""
421443
super().__init__(
422444
name=name,
423445
title=title,
424446
subtitle=subtitle,
425447
timestamp=timestamp,
448+
subtitle_toggle_label=subtitle_toggle_label,
426449
)
427450

428451
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, "")
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)