Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions ax/core/analysis_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from IPython.display import display, HTML, Markdown
from plotly.offline import get_plotlyjs

DEFAULT_SUBTITLE_TOGGLE_LABEL: str = "See more"

# Simple HTML template for rendering a card with a title, subtitle, and body with
# scrollable overflow. Subtitles are rendered in a <div> with max-height overflow
# so that any HTML content (tables, lists, etc.) is valid and clipped correctly
Expand Down Expand Up @@ -54,22 +56,24 @@
display: none;
}}
</style>
<div class="card">
<div class="card" id="{card_id}">
<div class="card-header">
<b>{title_str}</b>
<div class="card-subtitle">{subtitle_str}</div>
<button class="card-subtitle-toggle">See more</button>
<button class="card-subtitle-toggle">{toggle_text}</button>
</div>
<div class="card-body">
{body_html}
</div>
</div>
<script>
(function() {{
var card = document.currentScript.previousElementSibling;
var card = document.getElementById('{card_id}');
if (!card) return;
var subtitle = card.querySelector('.card-subtitle');
var toggle = card.querySelector('.card-subtitle-toggle');
if (subtitle && toggle) {{
var originalText = toggle.textContent;
requestAnimationFrame(function() {{
if (subtitle.scrollHeight > subtitle.clientHeight
|| subtitle.scrollWidth > subtitle.clientWidth) {{
Expand All @@ -81,7 +85,7 @@
subtitle.style.maxHeight = '1.4em';
subtitle.style.whiteSpace = 'nowrap';
subtitle.style.textOverflow = 'ellipsis';
toggle.textContent = 'See more';
toggle.textContent = originalText;
}} else {{
subtitle.style.maxHeight = 'none';
subtitle.style.whiteSpace = 'normal';
Expand Down Expand Up @@ -154,6 +158,7 @@ class AnalysisCardBase(SortableBase, ABC):

title: str
subtitle: str
subtitle_toggle_label: str

_timestamp: datetime

Expand All @@ -163,6 +168,7 @@ def __init__(
title: str,
subtitle: str,
timestamp: datetime | None = None,
subtitle_toggle_label: str = DEFAULT_SUBTITLE_TOGGLE_LABEL,
) -> None:
"""
Args:
Expand All @@ -175,10 +181,16 @@ def __init__(
timestamp: The time at which the Analysis was computed. This can be
especially useful when querying the database for the most recently
produced artifacts.
subtitle_toggle_label: Custom label for the subtitle
expansion toggle. When non-empty, replaces the default
"See more" text. An empty string is converted to the
default. Persisted to storage and used by both notebook
and web UI rendering.
"""
self.name = name
self.title = title
self.subtitle = subtitle
self.subtitle_toggle_label = subtitle_toggle_label
self._timestamp = timestamp if timestamp is not None else datetime.now()

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

def _to_html(self, depth: int) -> str:
toggle_text = self.subtitle_toggle_label or DEFAULT_SUBTITLE_TOGGLE_LABEL
# Use id(self) so each card gets a unique HTML element ID even when
# multiple cards share the same name.
card_id = f"ax-card-{id(self)}"
return html_card_template.format(
card_id=card_id,
title_str=self.title,
subtitle_str=self.subtitle,
body_html=self._body_html(depth=depth),
toggle_text=toggle_text,
)


Expand All @@ -282,6 +300,7 @@ def __init__(
subtitle: str | None,
children: Sequence[AnalysisCardBase],
timestamp: datetime | None = None,
subtitle_toggle_label: str = DEFAULT_SUBTITLE_TOGGLE_LABEL,
) -> None:
"""
Args:
Expand All @@ -294,12 +313,16 @@ def __init__(
timestamp: The time at which the Analysis was computed. This can be
especially useful when querying the database for the most recently
produced artifacts.
subtitle_toggle_label: Custom label for the subtitle expansion
toggle. When non-empty, replaces the default "See more" text.
An empty string is converted to the default.
"""
super().__init__(
name=name,
title=title,
subtitle=subtitle if subtitle is not None else "",
timestamp=timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)

self.children = [
Expand Down Expand Up @@ -404,6 +427,7 @@ def __init__(
df: pd.DataFrame,
blob: str,
timestamp: datetime | None = None,
subtitle_toggle_label: str = DEFAULT_SUBTITLE_TOGGLE_LABEL,
) -> None:
"""
Args:
Expand All @@ -417,12 +441,16 @@ def __init__(
timestamp: The time at which the Analysis was computed. This can be
especially useful when querying the database for the most recently
produced artifacts.
subtitle_toggle_label: Custom label for the subtitle expansion
toggle. When non-empty, replaces the default "See more" text.
An empty string is converted to the default.
"""
super().__init__(
name=name,
title=title,
subtitle=subtitle,
timestamp=timestamp,
subtitle_toggle_label=subtitle_toggle_label,
)

self.df = df
Expand Down
73 changes: 59 additions & 14 deletions ax/core/tests/test_analysis_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,36 @@
from plotly import graph_objects as go, io as pio


class TestAnalysisCard(TestCase):
def test_hierarchy_str(self) -> None:
test_df = pd.DataFrame(
columns=["a", "b"],
data=[
[1, 2],
[3, 4],
],
)
DUMMY_DF: pd.DataFrame = pd.DataFrame(
columns=["a", "b"],
data=[[1, 2], [3, 4]],
)


base_analysis_card = AnalysisCard(
class TestAnalysisCard(TestCase):
def setUp(self) -> None:
super().setUp()
self.base_card = AnalysisCard(
name="test_base_analysis_card",
title="test_base_analysis_card_title",
subtitle="test_subtitle",
df=test_df,
df=DUMMY_DF,
blob="test blob",
)

def test_hierarchy_str(self) -> None:
markdown_analysis_card = MarkdownAnalysisCard(
name="test_markdown_analysis_card",
title="test_markdown_analysis_card_title",
subtitle="test_subtitle",
df=test_df,
df=DUMMY_DF,
blob="This is some **really cool** markdown",
)
plotly_analysis_card = PlotlyAnalysisCard(
name="test_plotly_analysis_card",
title="test_plotly_analysis_card_title",
subtitle="test_subtitle",
df=test_df,
df=DUMMY_DF,
blob=pio.to_json(go.Figure()),
)

Expand All @@ -51,7 +52,7 @@ def test_hierarchy_str(self) -> None:
name="small_group",
title="Small Group",
subtitle="This is a small group with just a few cards",
children=[base_analysis_card, markdown_analysis_card],
children=[self.base_card, markdown_analysis_card],
)
big_group = AnalysisCardGroup(
name="big_group",
Expand All @@ -78,3 +79,47 @@ def test_not_applicable_card(self) -> None:
blob="Explanation text.",
)
self.assertIn("Explanation text.", card._body_html(depth=0))

def test_subtitle_toggle_label_rendering(self) -> None:
"""Verify subtitle_toggle_label controls toggle button text in HTML."""
for label, expected_text in (
("", "See more"),
(
"Expand to see annotated parameters.",
"Expand to see annotated parameters.",
),
):
with self.subTest(label=label):
card = AnalysisCard(
name="Test",
title="Title",
subtitle="A long subtitle",
df=pd.DataFrame(),
blob="blob",
subtitle_toggle_label=label,
)
self.assertEqual(card.subtitle_toggle_label, label)
html = card._repr_html_()
self.assertIn(expected_text, html)

def test_analysis_card_group_html_does_not_render_toggle(self) -> None:
"""AnalysisCardGroup._to_html uses html_group_card_template which renders
the subtitle as a plain <p> tag (no collapsible toggle). Verify the group's
own subtitle_toggle_label is stored but not rendered in the group header."""

group = AnalysisCardGroup(
name="G",
title="GT",
subtitle="GS",
children=[self.base_card],
subtitle_toggle_label="Custom toggle.",
)
self.assertEqual(group.subtitle_toggle_label, "Custom toggle.")

html = group._to_html(depth=0)

# The group template uses a plain <p> for subtitles, not the
# collapsible card-subtitle + toggle-button pattern.
self.assertNotIn("Custom toggle.", html)
self.assertIn('<p class="group-subtitle">', html)
self.assertIn("GS", html)
2 changes: 2 additions & 0 deletions ax/storage/json_store/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def analysis_card_to_dict(card: AnalysisCard) -> dict[str, Any]:
"df": card.df,
"blob": card.blob,
"timestamp": card._timestamp,
"subtitle_toggle_label": card.subtitle_toggle_label,
}


Expand All @@ -101,6 +102,7 @@ def analysis_card_group_to_dict(group: AnalysisCardGroup) -> dict[str, Any]:
"subtitle": group.subtitle,
"children": group.children,
"timestamp": group._timestamp,
"subtitle_toggle_label": group.subtitle_toggle_label,
}


Expand Down
61 changes: 61 additions & 0 deletions ax/storage/json_store/tests/test_json_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@
subtitle="subtitle",
df=pd.DataFrame({"a": [1, 2]}),
blob="blob_str",
subtitle_toggle_label="Expand to see details.",
),
),
(
Expand Down Expand Up @@ -486,6 +487,7 @@
subtitle="na subtitle",
df=pd.DataFrame(),
blob="Not enough data.",
subtitle_toggle_label="Expand to see why.",
),
),
(
Expand All @@ -510,6 +512,7 @@
blob="# md",
),
],
subtitle_toggle_label="Expand to see children.",
),
),
]
Expand Down Expand Up @@ -649,6 +652,64 @@ def test_EncodeDecode(self) -> None:
else:
raise e

def test_EncodeDecodeAnalysisCardSubtitleToggleLabel(self) -> None:
"""Verify decoding old JSON missing subtitle_toggle_label falls back to
the constructor default (backwards compatibility).
"""

# GIVEN old AnalysisCard JSON missing the subtitle_toggle_label key
with self.subTest(msg="backward compatible - AnalysisCard"):
restored = object_from_json(
{
"__type": "AnalysisCard",
"name": "OldCard",
"title": "T",
"subtitle": "S",
"df": {"__type": "DataFrame", "value": "{}"},
"blob": "b",
"timestamp": {
"__type": "datetime",
"value": "2025-01-01 00:00:00.000000",
},
},
decoder_registry=CORE_DECODER_REGISTRY,
class_decoder_registry=CORE_CLASS_DECODER_REGISTRY,
)
self.assertEqual(restored.subtitle_toggle_label, "See more")

# GIVEN old AnalysisCardGroup JSON missing subtitle_toggle_label
with self.subTest(msg="backward compatible - AnalysisCardGroup"):
restored_group = object_from_json(
{
"__type": "AnalysisCardGroup",
"name": "OldGroup",
"title": "GT",
"subtitle": "GS",
"children": [
{
"__type": "AnalysisCard",
"name": "C",
"title": "CT",
"subtitle": "CS",
"df": {"__type": "DataFrame", "value": "{}"},
"blob": "b",
"timestamp": {
"__type": "datetime",
"value": "2025-01-01 00:00:00.000000",
},
}
],
"timestamp": {
"__type": "datetime",
"value": "2025-01-01 00:00:00.000000",
},
},
decoder_registry=CORE_DECODER_REGISTRY,
class_decoder_registry=CORE_CLASS_DECODER_REGISTRY,
)
self.assertEqual(restored_group.subtitle_toggle_label, "")
self.assertEqual(restored_group.children[0].subtitle_toggle_label, "")

def test_EncodeDecode_dataclass_with_initvar(self) -> None:
@dataclasses.dataclass
class TestDataclass:
Expand Down
Loading
Loading