diff --git a/ax/core/analysis_card.py b/ax/core/analysis_card.py index 1c76068cfe9..497c7ace31b 100644 --- a/ax/core/analysis_card.py +++ b/ax/core/analysis_card.py @@ -19,7 +19,10 @@ from plotly.offline import get_plotlyjs # Simple HTML template for rendering a card with a title, subtitle, and body with -# scrollable overflow. +# scrollable overflow. Subtitles are rendered in a
with max-height overflow +# so that any HTML content (tables, lists, etc.) is valid and clipped correctly +# in the collapsed state. A "See more"/"See less" toggle appears when content +# overflows (matching the web UI pattern). html_card_template = """
-
- {title_str} -

{subtitle_str}

-
+ {title_str} +
{subtitle_str}
+
{body_html}
+ """ # Simple HTML template for rendering a *group* card with a title, subtitle, and @@ -120,6 +155,7 @@ class AnalysisCardBase(SortableBase, ABC): title: str subtitle: str + subtitle_toggle_label: str _timestamp: datetime @@ -129,6 +165,7 @@ def __init__( title: str, subtitle: str, timestamp: datetime | None = None, + subtitle_toggle_label: str = "", ) -> None: """ Args: @@ -141,10 +178,14 @@ 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. 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 @@ -224,10 +265,12 @@ 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 "See more" return html_card_template.format( title_str=self.title, subtitle_str=self.subtitle, body_html=self._body_html(depth=depth), + toggle_text=toggle_text, ) @@ -248,6 +291,7 @@ def __init__( subtitle: str | None, children: Sequence[AnalysisCardBase], timestamp: datetime | None = None, + subtitle_toggle_label: str = "", ) -> None: """ Args: @@ -260,12 +304,15 @@ 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 """ super().__init__( name=name, title=title, subtitle=subtitle if subtitle is not None else "", timestamp=timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) self.children = [ @@ -370,6 +417,7 @@ def __init__( df: pd.DataFrame, blob: str, timestamp: datetime | None = None, + subtitle_toggle_label: str = "", ) -> None: """ Args: @@ -383,12 +431,15 @@ 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 """ super().__init__( name=name, title=title, subtitle=subtitle, timestamp=timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) self.df = df diff --git a/ax/core/tests/test_analysis_card.py b/ax/core/tests/test_analysis_card.py index a092814e157..aee09ebea57 100644 --- a/ax/core/tests/test_analysis_card.py +++ b/ax/core/tests/test_analysis_card.py @@ -78,3 +78,25 @@ 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(): + 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) diff --git a/ax/storage/json_store/encoders.py b/ax/storage/json_store/encoders.py index d6f960ffdcc..d2f61e3192a 100644 --- a/ax/storage/json_store/encoders.py +++ b/ax/storage/json_store/encoders.py @@ -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, } @@ -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, } diff --git a/ax/storage/json_store/tests/test_json_store.py b/ax/storage/json_store/tests/test_json_store.py index 783bfce31d9..f73ab81256b 100644 --- a/ax/storage/json_store/tests/test_json_store.py +++ b/ax/storage/json_store/tests/test_json_store.py @@ -421,6 +421,7 @@ subtitle="subtitle", df=pd.DataFrame({"a": [1, 2]}), blob="blob_str", + subtitle_toggle_label="Expand to see details.", ), ), ( @@ -495,6 +496,7 @@ blob="# md", ), ], + subtitle_toggle_label="Expand to see children.", ), ), ] diff --git a/ax/storage/sqa_store/decoder.py b/ax/storage/sqa_store/decoder.py index 9fb27215f44..c2426bffc10 100644 --- a/ax/storage/sqa_store/decoder.py +++ b/ax/storage/sqa_store/decoder.py @@ -1163,6 +1163,7 @@ def analysis_card_from_sqa( analysis_card_sqa.title if analysis_card_sqa.title is not None else "" ) subtitle = analysis_card_sqa.subtitle + subtitle_toggle_label = analysis_card_sqa.subtitle_toggle_label or "" return AnalysisCardGroup( name=analysis_card_sqa.name, @@ -1170,12 +1171,14 @@ def analysis_card_from_sqa( subtitle=subtitle, children=children, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) title = none_throws(analysis_card_sqa.title) subtitle = none_throws(analysis_card_sqa.subtitle) blob = none_throws(analysis_card_sqa.blob) blob_annotation = analysis_card_sqa.blob_annotation + subtitle_toggle_label = analysis_card_sqa.subtitle_toggle_label or "" if blob_annotation == "not_applicable_state": return NotApplicableStateAnalysisCard( @@ -1185,6 +1188,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) if blob_annotation == "error": return ErrorAnalysisCard( @@ -1194,6 +1198,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) if blob_annotation == "plotly": return PlotlyAnalysisCard( @@ -1203,6 +1208,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) if blob_annotation == "markdown": return MarkdownAnalysisCard( @@ -1212,6 +1218,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) if blob_annotation == "healthcheck": return HealthcheckAnalysisCard( @@ -1221,6 +1228,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) if blob_annotation == "graphviz": return GraphvizAnalysisCard( @@ -1230,6 +1238,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) return AnalysisCard( name=analysis_card_sqa.name, @@ -1238,6 +1247,7 @@ def analysis_card_from_sqa( df=read_json(StringIO(analysis_card_sqa.dataframe_json)), blob=blob, timestamp=analysis_card_sqa.timestamp, + subtitle_toggle_label=subtitle_toggle_label, ) def _metric_from_sqa_util(self, metric_sqa: SQAMetric) -> Metric: diff --git a/ax/storage/sqa_store/encoder.py b/ax/storage/sqa_store/encoder.py index 63ab30eaa41..9bd980e426f 100644 --- a/ax/storage/sqa_store/encoder.py +++ b/ax/storage/sqa_store/encoder.py @@ -1264,6 +1264,7 @@ def analysis_card_to_sqa( dataframe_json=None, blob=None, blob_annotation=None, + subtitle_toggle_label=analysis_card.subtitle_toggle_label or None, ) for i, child_card in enumerate(analysis_card.children): @@ -1304,4 +1305,5 @@ def analysis_card_to_sqa( dataframe_json=card.df.to_json(), blob=card.blob, blob_annotation=blob_annotation, + subtitle_toggle_label=card.subtitle_toggle_label or None, ) diff --git a/ax/storage/sqa_store/sqa_classes.py b/ax/storage/sqa_store/sqa_classes.py index ee656e8483a..5547030d950 100644 --- a/ax/storage/sqa_store/sqa_classes.py +++ b/ax/storage/sqa_store/sqa_classes.py @@ -455,6 +455,9 @@ class SQAAnalysisCard(Base): blob_annotation: Column[str | None] = Column( String(NAME_OR_TYPE_FIELD_LENGTH), nullable=True ) + subtitle_toggle_label: Column[str | None] = Column( + "subtitle_toggle_label", String(LONG_STRING_FIELD_LENGTH), nullable=True + ) parent: Any = relationship( "SQAAnalysisCard", back_populates="children", diff --git a/ax/storage/sqa_store/tests/test_sqa_store.py b/ax/storage/sqa_store/tests/test_sqa_store.py index 07de5407c20..8e1f7eb7dda 100644 --- a/ax/storage/sqa_store/tests/test_sqa_store.py +++ b/ax/storage/sqa_store/tests/test_sqa_store.py @@ -3015,6 +3015,7 @@ def test_analysis_card(self) -> None: subtitle="test_subtitle", df=test_df, blob="test blob", + subtitle_toggle_label="Expand to see details.", ) markdown_analysis_card = MarkdownAnalysisCard( name="test_markdown_analysis_card", @@ -3038,6 +3039,7 @@ def test_analysis_card(self) -> None: title="Small Group", subtitle="This is a small group with just a few cards", children=[base_analysis_card, markdown_analysis_card, plotly_analysis_card], + subtitle_toggle_label="Expand to see group children.", ) big_group = AnalysisCardGroup( name="big_group", @@ -3067,12 +3069,14 @@ def test_analysis_card(self) -> None: self.assertEqual(loaded_big_group.name, big_group.name) self.assertEqual(loaded_big_group.title, big_group.title) self.assertEqual(loaded_big_group.subtitle, big_group.subtitle) + self.assertEqual(loaded_big_group.subtitle_toggle_label, "") loaded_big_group_plotly = assert_is_instance( loaded_big_group.children[0], PlotlyAnalysisCard ) self.assertEqual(loaded_big_group_plotly.name, plotly_analysis_card.name) self.assertEqual(loaded_big_group_plotly.blob, plotly_analysis_card.blob) + self.assertEqual(loaded_big_group_plotly.subtitle_toggle_label, "") loaded_small_group = assert_is_instance( loaded_big_group.children[1], AnalysisCardGroup @@ -3080,24 +3084,33 @@ def test_analysis_card(self) -> None: self.assertEqual(loaded_small_group.name, small_group.name) self.assertEqual(loaded_small_group.title, small_group.title) self.assertEqual(loaded_small_group.subtitle, small_group.subtitle) + self.assertEqual( + loaded_small_group.subtitle_toggle_label, + "Expand to see group children.", + ) loaded_base = assert_is_instance( loaded_small_group.children[0], AnalysisCard ) self.assertEqual(loaded_base.name, base_analysis_card.name) self.assertEqual(loaded_base.blob, base_analysis_card.blob) + self.assertEqual( + loaded_base.subtitle_toggle_label, "Expand to see details." + ) loaded_markdown = assert_is_instance( loaded_small_group.children[1], MarkdownAnalysisCard ) self.assertEqual(loaded_markdown.name, markdown_analysis_card.name) self.assertEqual(loaded_markdown.blob, markdown_analysis_card.blob) + self.assertEqual(loaded_markdown.subtitle_toggle_label, "") loaded_small_group_plotly = assert_is_instance( loaded_small_group.children[2], PlotlyAnalysisCard ) self.assertEqual(loaded_small_group_plotly.name, plotly_analysis_card.name) self.assertEqual(loaded_small_group_plotly.blob, plotly_analysis_card.blob) + self.assertEqual(loaded_small_group_plotly.subtitle_toggle_label, "") def test_delete_generation_strategy(self) -> None: # GIVEN an experiment with a generation strategy