mirror of
https://github.com/apache/superset.git
synced 2026-07-08 15:55:33 +00:00
Compare commits
5 Commits
chore/ci/s
...
aminghader
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4044541f60 | ||
|
|
847b841056 | ||
|
|
58abd8e066 | ||
|
|
af4103781c | ||
|
|
446e0df0ba |
@@ -480,7 +480,17 @@ class AddChartToDashboardRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
chart_id: int = Field(..., description="ID of the chart to add to the dashboard")
|
chart_id: int = Field(..., description="ID of the chart to add to the dashboard")
|
||||||
target_tab: str | None = Field(
|
target_tab: str | None = Field(
|
||||||
None, description="Target tab name (if dashboard has tabs)"
|
None,
|
||||||
|
min_length=1,
|
||||||
|
description=(
|
||||||
|
"Tab to place the chart in. Accepts a tab display name "
|
||||||
|
"(e.g. 'Sales') or a tab component ID (e.g. 'TAB-abc123'). "
|
||||||
|
"Display-name matching is case-insensitive and strips all emoji; "
|
||||||
|
"component ID matching is case-sensitive and exact. "
|
||||||
|
"When not found, the error response lists all available tab names. "
|
||||||
|
"When omitted on a tabbed dashboard the chart is placed in the "
|
||||||
|
"first tab."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,29 +149,46 @@ def _first_tab_from_groups(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_available_tab_names(layout: Dict[str, Any]) -> list[str]:
|
||||||
|
"""Collect display names (or IDs) of all TAB components in the layout."""
|
||||||
|
names: list[str] = []
|
||||||
|
for tabs_children in _collect_tabs_groups(layout):
|
||||||
|
for tab_id in tabs_children:
|
||||||
|
tab = layout.get(tab_id)
|
||||||
|
if not tab or tab.get("type") != "TAB":
|
||||||
|
continue
|
||||||
|
text = (tab.get("meta") or {}).get("text", "")
|
||||||
|
names.append(text if text else tab_id)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def _find_tab_insert_target(
|
def _find_tab_insert_target(
|
||||||
layout: Dict[str, Any], target_tab: str | None = None
|
layout: Dict[str, Any], target_tab: str | None = None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Detect if the dashboard uses tabs and return the appropriate tab's ID.
|
Detect if the dashboard uses tabs and return the appropriate tab's ID.
|
||||||
|
|
||||||
If *target_tab* is provided the function first tries to match it against
|
When *target_tab* is ``None`` the function returns the first TAB child so
|
||||||
tab ``meta.text`` (display name) or the raw component ID. When no match
|
that new rows are placed inside the tab structure rather than directly
|
||||||
is found (or *target_tab* is ``None``) the first ``TAB`` child is used as
|
under ``GRID_ID``.
|
||||||
a fallback so that new rows are still placed inside the tab structure
|
|
||||||
rather than directly under ``GRID_ID``.
|
When *target_tab* is provided the function tries to match it against tab
|
||||||
|
``meta.text`` (display name) or the raw component ID. If no match is
|
||||||
|
found ``None`` is returned — the caller is responsible for surfacing an
|
||||||
|
error rather than silently placing the chart in the wrong tab.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The ID of the matched (or first) TAB component, or ``None`` if the
|
The ID of the matched (or first) TAB component, or ``None``.
|
||||||
dashboard does not use top-level tabs.
|
|
||||||
"""
|
"""
|
||||||
groups = _collect_tabs_groups(layout)
|
groups = _collect_tabs_groups(layout)
|
||||||
|
|
||||||
if target_tab:
|
if target_tab is not None:
|
||||||
for tabs_children in groups:
|
for tabs_children in groups:
|
||||||
matched = _match_tab_in_children(layout, tabs_children, target_tab)
|
matched = _match_tab_in_children(layout, tabs_children, target_tab)
|
||||||
if matched:
|
if matched:
|
||||||
return matched
|
return matched
|
||||||
|
# target_tab specified but not found — signal mismatch to the caller.
|
||||||
|
return None
|
||||||
|
|
||||||
return _first_tab_from_groups(layout, groups)
|
return _first_tab_from_groups(layout, groups)
|
||||||
|
|
||||||
@@ -316,6 +333,45 @@ def _ensure_layout_structure(
|
|||||||
layout["DASHBOARD_VERSION_KEY"] = "v2"
|
layout["DASHBOARD_VERSION_KEY"] = "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_parent_container(
|
||||||
|
layout: Dict[str, Any],
|
||||||
|
dashboard_id: int,
|
||||||
|
target_tab: str | None,
|
||||||
|
) -> tuple[str, None] | tuple[None, AddChartToDashboardResponse]:
|
||||||
|
"""Return (parent_id, None) on success or (None, error_response) on mismatch.
|
||||||
|
|
||||||
|
When *target_tab* is specified and not found the caller receives a
|
||||||
|
descriptive error listing available tabs rather than a silent fallback.
|
||||||
|
"""
|
||||||
|
tab_target = _find_tab_insert_target(layout, target_tab=target_tab)
|
||||||
|
|
||||||
|
if target_tab is not None and tab_target is None:
|
||||||
|
available = _collect_available_tab_names(layout)
|
||||||
|
if available:
|
||||||
|
tab_list = ", ".join(f"'{t}'" for t in available)
|
||||||
|
return None, AddChartToDashboardResponse(
|
||||||
|
dashboard=None,
|
||||||
|
dashboard_url=None,
|
||||||
|
position=None,
|
||||||
|
error=(
|
||||||
|
f"Tab '{target_tab}' not found in dashboard {dashboard_id}. "
|
||||||
|
f"Available tabs: {tab_list}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return None, AddChartToDashboardResponse(
|
||||||
|
dashboard=None,
|
||||||
|
dashboard_url=None,
|
||||||
|
position=None,
|
||||||
|
error=(
|
||||||
|
f"Dashboard {dashboard_id} has no tabs. "
|
||||||
|
"Remove the target_tab parameter to add the chart to "
|
||||||
|
"the default grid layout."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tab_target if tab_target else "GRID_ID", None
|
||||||
|
|
||||||
|
|
||||||
def _find_and_authorize_dashboard(
|
def _find_and_authorize_dashboard(
|
||||||
dashboard_id: int,
|
dashboard_id: int,
|
||||||
) -> tuple[Any, AddChartToDashboardResponse | None]:
|
) -> tuple[Any, AddChartToDashboardResponse | None]:
|
||||||
@@ -369,7 +425,7 @@ def _find_and_authorize_dashboard(
|
|||||||
destructiveHint=False,
|
destructiveHint=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def add_chart_to_existing_dashboard(
|
def add_chart_to_existing_dashboard( # noqa: C901
|
||||||
request: AddChartToDashboardRequest, ctx: Context
|
request: AddChartToDashboardRequest, ctx: Context
|
||||||
) -> AddChartToDashboardResponse:
|
) -> AddChartToDashboardResponse:
|
||||||
"""
|
"""
|
||||||
@@ -443,11 +499,16 @@ def add_chart_to_existing_dashboard(
|
|||||||
# Generate a unique ROW ID for the new row
|
# Generate a unique ROW ID for the new row
|
||||||
row_key = _find_next_row_position(current_layout)
|
row_key = _find_next_row_position(current_layout)
|
||||||
|
|
||||||
# Detect tabbed dashboards and resolve target_tab by name or ID
|
# Detect tabbed dashboards and resolve target_tab by name or ID.
|
||||||
tab_target = _find_tab_insert_target(
|
parent_id, tab_error = _resolve_parent_container(
|
||||||
current_layout, target_tab=request.target_tab
|
current_layout, request.dashboard_id, request.target_tab
|
||||||
)
|
)
|
||||||
parent_id = tab_target if tab_target else "GRID_ID"
|
if tab_error is not None:
|
||||||
|
return tab_error
|
||||||
|
if parent_id is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"unreachable: tab_error is None implies parent_id is str"
|
||||||
|
)
|
||||||
|
|
||||||
# Add chart, column, and row to layout
|
# Add chart, column, and row to layout
|
||||||
chart_key, column_key, row_key = _add_chart_to_layout(
|
chart_key, column_key, row_key = _add_chart_to_layout(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def mcp_server() -> object:
|
def mcp_server() -> object:
|
||||||
"""Return the FastMCP app instance for use in MCP client tests."""
|
"""Return the FastMCP app instance for use in MCP client tests."""
|
||||||
return mcp
|
return mcp
|
||||||
@@ -138,7 +138,7 @@ def _mock_dashboard(
|
|||||||
|
|
||||||
|
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio()
|
||||||
async def test_dashboard_not_found(mock_find_by_id: Mock, mcp_server: object) -> None:
|
async def test_dashboard_not_found(mock_find_by_id: Mock, mcp_server: object) -> None:
|
||||||
"""Returns a clear error when the target dashboard does not exist."""
|
"""Returns a clear error when the target dashboard does not exist."""
|
||||||
mock_find_by_id.return_value = None
|
mock_find_by_id.return_value = None
|
||||||
@@ -157,7 +157,7 @@ async def test_dashboard_not_found(mock_find_by_id: Mock, mcp_server: object) ->
|
|||||||
|
|
||||||
@patch("superset.security_manager.raise_for_ownership")
|
@patch("superset.security_manager.raise_for_ownership")
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio()
|
||||||
async def test_permission_denied(
|
async def test_permission_denied(
|
||||||
mock_find_by_id: Mock, mock_raise_for_ownership: Mock, mcp_server: object
|
mock_find_by_id: Mock, mock_raise_for_ownership: Mock, mcp_server: object
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -203,7 +203,7 @@ async def test_permission_denied(
|
|||||||
@patch("superset.db.session.get")
|
@patch("superset.db.session.get")
|
||||||
@patch("superset.security_manager.raise_for_ownership")
|
@patch("superset.security_manager.raise_for_ownership")
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio()
|
||||||
async def test_chart_not_found(
|
async def test_chart_not_found(
|
||||||
mock_find_by_id: Mock,
|
mock_find_by_id: Mock,
|
||||||
mock_raise_for_ownership: Mock,
|
mock_raise_for_ownership: Mock,
|
||||||
@@ -231,7 +231,7 @@ async def test_chart_not_found(
|
|||||||
@patch("superset.db.session.get")
|
@patch("superset.db.session.get")
|
||||||
@patch("superset.security_manager.raise_for_ownership")
|
@patch("superset.security_manager.raise_for_ownership")
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio()
|
||||||
async def test_chart_already_in_dashboard(
|
async def test_chart_already_in_dashboard(
|
||||||
mock_find_by_id: Mock,
|
mock_find_by_id: Mock,
|
||||||
mock_raise_for_ownership: Mock,
|
mock_raise_for_ownership: Mock,
|
||||||
@@ -261,7 +261,7 @@ async def test_chart_already_in_dashboard(
|
|||||||
@patch("superset.db.session.get")
|
@patch("superset.db.session.get")
|
||||||
@patch("superset.security_manager.raise_for_ownership")
|
@patch("superset.security_manager.raise_for_ownership")
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio()
|
||||||
async def test_successful_add(
|
async def test_successful_add(
|
||||||
mock_find_by_id: Mock,
|
mock_find_by_id: Mock,
|
||||||
mock_raise_for_ownership: Mock,
|
mock_raise_for_ownership: Mock,
|
||||||
@@ -295,3 +295,17 @@ async def test_successful_add(
|
|||||||
assert "/superset/dashboard/1/" in content["dashboard_url"]
|
assert "/superset/dashboard/1/" in content["dashboard_url"]
|
||||||
assert content["position"] is not None
|
assert content["position"] is not None
|
||||||
assert "chart_key" in content["position"]
|
assert "chart_key" in content["position"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_target_tab_rejected_by_schema() -> None:
|
||||||
|
"""Empty string target_tab is rejected at schema layer, not as 'Tab not found'."""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from superset.mcp_service.dashboard.schemas import AddChartToDashboardRequest
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AddChartToDashboardRequest(dashboard_id=1, chart_id=10, target_tab="")
|
||||||
|
|
||||||
|
# None is valid (tab omitted)
|
||||||
|
req = AddChartToDashboardRequest(dashboard_id=1, chart_id=10, target_tab=None)
|
||||||
|
assert req.target_tab is None
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from superset.mcp_service.chart.chart_utils import DatasetValidationResult
|
|||||||
from superset.mcp_service.dashboard.constants import generate_id
|
from superset.mcp_service.dashboard.constants import generate_id
|
||||||
from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard import (
|
from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard import (
|
||||||
_add_chart_to_layout,
|
_add_chart_to_layout,
|
||||||
|
_collect_available_tab_names,
|
||||||
_ensure_layout_structure,
|
_ensure_layout_structure,
|
||||||
_find_next_row_position,
|
_find_next_row_position,
|
||||||
_find_tab_insert_target,
|
_find_tab_insert_target,
|
||||||
@@ -1059,6 +1060,110 @@ class TestAddChartToExistingDashboard:
|
|||||||
assert "TAB-tab2" in chart_parents
|
assert "TAB-tab2" in chart_parents
|
||||||
assert "TAB-tab1" not in chart_parents
|
assert "TAB-tab1" not in chart_parents
|
||||||
|
|
||||||
|
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
|
||||||
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
|
@patch("superset.db.session")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_chart_target_tab_not_found(
|
||||||
|
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
|
||||||
|
):
|
||||||
|
"""target_tab specified but no matching tab → descriptive error listing
|
||||||
|
available tabs, not a silent fallback to the first tab."""
|
||||||
|
mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard")
|
||||||
|
mock_dashboard.slices = [_mock_chart(id=10)]
|
||||||
|
mock_dashboard.position_json = json.dumps(
|
||||||
|
{
|
||||||
|
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||||
|
"GRID_ID": {
|
||||||
|
"children": ["TABS-abc123"],
|
||||||
|
"id": "GRID_ID",
|
||||||
|
"parents": ["ROOT_ID"],
|
||||||
|
"type": "GRID",
|
||||||
|
},
|
||||||
|
"TABS-abc123": {
|
||||||
|
"children": ["TAB-tab1", "TAB-tab2"],
|
||||||
|
"id": "TABS-abc123",
|
||||||
|
"parents": ["ROOT_ID", "GRID_ID"],
|
||||||
|
"type": "TABS",
|
||||||
|
},
|
||||||
|
"TAB-tab1": {
|
||||||
|
"children": [],
|
||||||
|
"id": "TAB-tab1",
|
||||||
|
"meta": {"text": "Overview"},
|
||||||
|
"parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
|
||||||
|
"type": "TAB",
|
||||||
|
},
|
||||||
|
"TAB-tab2": {
|
||||||
|
"children": [],
|
||||||
|
"id": "TAB-tab2",
|
||||||
|
"meta": {"text": "Details"},
|
||||||
|
"parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
|
||||||
|
"type": "TAB",
|
||||||
|
},
|
||||||
|
"DASHBOARD_VERSION_KEY": "v2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_chart = _mock_chart(id=30)
|
||||||
|
mock_db_session.get.return_value = mock_chart
|
||||||
|
mock_find_dashboard.return_value = mock_dashboard
|
||||||
|
|
||||||
|
request = {"dashboard_id": 3, "chart_id": 30, "target_tab": "Nonexistent Tab"}
|
||||||
|
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"add_chart_to_existing_dashboard", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.structured_content["error"] is not None
|
||||||
|
error = result.structured_content["error"]
|
||||||
|
assert "Nonexistent Tab" in error
|
||||||
|
assert "not found" in error
|
||||||
|
# Available tabs should be listed
|
||||||
|
assert "Overview" in error
|
||||||
|
assert "Details" in error
|
||||||
|
# No layout mutation should have been persisted
|
||||||
|
mock_update_command.assert_not_called()
|
||||||
|
|
||||||
|
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
|
||||||
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
|
@patch("superset.db.session")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_chart_target_tab_on_non_tabbed_dashboard(
|
||||||
|
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
|
||||||
|
):
|
||||||
|
"""target_tab on a dashboard with no tabs → descriptive error."""
|
||||||
|
mock_dashboard = _mock_dashboard(id=5, title="Flat Dashboard")
|
||||||
|
mock_dashboard.slices = []
|
||||||
|
mock_dashboard.position_json = json.dumps(
|
||||||
|
{
|
||||||
|
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||||
|
"GRID_ID": {
|
||||||
|
"children": [],
|
||||||
|
"id": "GRID_ID",
|
||||||
|
"parents": ["ROOT_ID"],
|
||||||
|
"type": "GRID",
|
||||||
|
},
|
||||||
|
"DASHBOARD_VERSION_KEY": "v2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_chart = _mock_chart(id=99)
|
||||||
|
mock_db_session.get.return_value = mock_chart
|
||||||
|
mock_find_dashboard.return_value = mock_dashboard
|
||||||
|
|
||||||
|
request = {"dashboard_id": 5, "chart_id": 99, "target_tab": "Sales"}
|
||||||
|
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"add_chart_to_existing_dashboard", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.structured_content["error"] is not None
|
||||||
|
error = result.structured_content["error"]
|
||||||
|
assert "no tabs" in error.lower()
|
||||||
|
assert "target_tab" in error
|
||||||
|
# No layout mutation should have been persisted
|
||||||
|
mock_update_command.assert_not_called()
|
||||||
|
|
||||||
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
|
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||||
@patch("superset.db.session")
|
@patch("superset.db.session")
|
||||||
@@ -1311,9 +1416,9 @@ class TestLayoutHelpers:
|
|||||||
}
|
}
|
||||||
assert _find_tab_insert_target(layout, target_tab="TAB-second") == "TAB-second"
|
assert _find_tab_insert_target(layout, target_tab="TAB-second") == "TAB-second"
|
||||||
|
|
||||||
def test_find_tab_insert_target_unmatched_falls_back_to_first(self):
|
def test_find_tab_insert_target_unmatched_returns_none(self):
|
||||||
"""Test _find_tab_insert_target falls back to first tab when target_tab
|
"""Test _find_tab_insert_target returns None when target_tab doesn't
|
||||||
doesn't match any tab name or ID."""
|
match any tab name or ID, so the caller can return a descriptive error."""
|
||||||
layout = {
|
layout = {
|
||||||
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
|
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
|
||||||
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
|
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
|
||||||
@@ -1328,9 +1433,17 @@ class TestLayoutHelpers:
|
|||||||
"meta": {"text": "Tab 2"},
|
"meta": {"text": "Tab 2"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert (
|
assert _find_tab_insert_target(layout, target_tab="Nonexistent Tab") is None
|
||||||
_find_tab_insert_target(layout, target_tab="Nonexistent Tab") == "TAB-first"
|
|
||||||
)
|
def test_find_tab_insert_target_empty_string_returns_none(self):
|
||||||
|
"""An empty-string target_tab is treated as specified-but-not-found,
|
||||||
|
not as 'no tab requested', so it returns None rather than first tab."""
|
||||||
|
layout = {
|
||||||
|
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
|
||||||
|
"TABS-main": {"children": ["TAB-first"], "type": "TABS"},
|
||||||
|
"TAB-first": {"children": [], "type": "TAB", "meta": {"text": "Tab 1"}},
|
||||||
|
}
|
||||||
|
assert _find_tab_insert_target(layout, target_tab="") is None
|
||||||
|
|
||||||
def test_find_tab_insert_target_tabs_under_root(self):
|
def test_find_tab_insert_target_tabs_under_root(self):
|
||||||
"""Test _find_tab_insert_target when TABS are under ROOT_ID (real layout)."""
|
"""Test _find_tab_insert_target when TABS are under ROOT_ID (real layout)."""
|
||||||
@@ -1358,6 +1471,35 @@ class TestLayoutHelpers:
|
|||||||
"""Test _find_tab_insert_target with missing GRID_ID."""
|
"""Test _find_tab_insert_target with missing GRID_ID."""
|
||||||
assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
|
assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
|
||||||
|
|
||||||
|
def test_collect_available_tab_names_returns_display_names(self):
|
||||||
|
"""_collect_available_tab_names returns each tab's display text."""
|
||||||
|
layout = {
|
||||||
|
"GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
|
||||||
|
"TABS-x": {"children": ["TAB-a", "TAB-b"], "type": "TABS"},
|
||||||
|
"TAB-a": {"children": [], "type": "TAB", "meta": {"text": "Overview"}},
|
||||||
|
"TAB-b": {"children": [], "type": "TAB", "meta": {"text": "Details"}},
|
||||||
|
}
|
||||||
|
names = _collect_available_tab_names(layout)
|
||||||
|
assert names == ["Overview", "Details"]
|
||||||
|
|
||||||
|
def test_collect_available_tab_names_falls_back_to_id(self):
|
||||||
|
"""_collect_available_tab_names uses component ID when text is empty."""
|
||||||
|
layout = {
|
||||||
|
"GRID_ID": {"children": ["TABS-x"], "type": "GRID"},
|
||||||
|
"TABS-x": {"children": ["TAB-a"], "type": "TABS"},
|
||||||
|
"TAB-a": {"children": [], "type": "TAB", "meta": {}},
|
||||||
|
}
|
||||||
|
names = _collect_available_tab_names(layout)
|
||||||
|
assert names == ["TAB-a"]
|
||||||
|
|
||||||
|
def test_collect_available_tab_names_no_tabs(self):
|
||||||
|
"""_collect_available_tab_names returns empty list for non-tabbed dashboards."""
|
||||||
|
layout = {
|
||||||
|
"GRID_ID": {"children": ["ROW-1"], "type": "GRID"},
|
||||||
|
"ROW-1": {"children": [], "type": "ROW"},
|
||||||
|
}
|
||||||
|
assert _collect_available_tab_names(layout) == []
|
||||||
|
|
||||||
def test_add_chart_to_layout_creates_column(self):
|
def test_add_chart_to_layout_creates_column(self):
|
||||||
"""Test that _add_chart_to_layout creates ROW > COLUMN > CHART."""
|
"""Test that _add_chart_to_layout creates ROW > COLUMN > CHART."""
|
||||||
layout = {
|
layout = {
|
||||||
|
|||||||
Reference in New Issue
Block a user