This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 6e94a6c21af fix(mcp): fix dashboard chart placement with proper COLUMN 
layout and tab support (#37970)
6e94a6c21af is described below

commit 6e94a6c21af3ffb132a4c386d83cba21e8d1259f
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Mon Feb 23 10:41:10 2026 -0500

    fix(mcp): fix dashboard chart placement with proper COLUMN layout and tab 
support (#37970)
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 superset/mcp_service/dashboard/constants.py        |  38 ++
 .../tool/add_chart_to_existing_dashboard.py        | 162 +++++++--
 .../dashboard/tool/generate_dashboard.py           |  42 ++-
 .../dashboard/tool/test_dashboard_generation.py    | 399 ++++++++++++++++++---
 4 files changed, 547 insertions(+), 94 deletions(-)

diff --git a/superset/mcp_service/dashboard/constants.py 
b/superset/mcp_service/dashboard/constants.py
new file mode 100644
index 00000000000..6010816eb3e
--- /dev/null
+++ b/superset/mcp_service/dashboard/constants.py
@@ -0,0 +1,38 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Shared constants for MCP dashboard tools.
+
+Values match frontend defaults from
+``superset-frontend/src/dashboard/util/constants.ts``.
+"""
+
+import uuid
+
+GRID_DEFAULT_CHART_WIDTH = 4
+GRID_COLUMN_COUNT = 12
+
+
+def generate_id(prefix: str) -> str:
+    """
+    Generate a component ID matching the frontend's nanoid-style pattern.
+
+    Uses a UUID hex prefix to produce IDs like ``ROW-a1b2c3d4`` which are
+    compatible with the frontend's ``nanoid()``-based ID generation.
+    """
+    return f"{prefix}-{uuid.uuid4().hex[:8]}"
diff --git 
a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py 
b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
index 86275cfb3ce..74730d799b3 100644
--- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
@@ -28,6 +28,11 @@ from fastmcp import Context
 from superset_core.mcp import tool
 
 from superset.extensions import event_logger
+from superset.mcp_service.dashboard.constants import (
+    generate_id,
+    GRID_COLUMN_COUNT,
+    GRID_DEFAULT_CHART_WIDTH,
+)
 from superset.mcp_service.dashboard.schemas import (
     AddChartToDashboardRequest,
     AddChartToDashboardResponse,
@@ -40,38 +45,95 @@ from superset.utils import json
 logger = logging.getLogger(__name__)
 
 
-def _find_next_row_position(layout: Dict[str, Any]) -> int:
+def _find_next_row_position(layout: Dict[str, Any]) -> str:
     """
-    Find the next available row position in the dashboard layout.
+    Generate a unique ROW ID for a new row in the dashboard layout.
+
+    Uses UUID-based IDs (e.g. ``ROW-a1b2c3d4``) instead of numeric indices
+    so that the IDs are compatible with real dashboard layouts that use
+    nanoid-style identifiers.
 
     Returns:
-        Row index for the new chart
+        A new unique ROW ID string.
     """
-    # Find existing rows
-    row_indices = []
-    for key in layout.keys():
-        if key.startswith("ROW-") and key[4:].isdigit():
-            row_indices.append(int(key[4:]))
+    row_key = generate_id("ROW")
+    # Ensure uniqueness (extremely unlikely collision, but safe)
+    while row_key in layout:
+        row_key = generate_id("ROW")
+    return row_key
+
 
-    # Return next available row index
-    return max(row_indices) + 1 if row_indices else 0
+def _find_tab_insert_target(layout: Dict[str, Any]) -> str | None:
+    """
+    Detect if the dashboard uses tabs and return the first tab's ID.
+
+    If ``GRID_ID`` has children that are ``TABS`` components, this walks
+    into the first ``TAB`` child so that new rows are placed inside the
+    active tab rather than directly under GRID_ID.
+
+    Returns:
+        The ID of the first TAB component, or ``None`` if the dashboard
+        does not use top-level tabs.
+    """
+    grid = layout.get("GRID_ID")
+    if not grid:
+        return None
+
+    for child_id in grid.get("children", []):
+        child = layout.get(child_id)
+        if child and child.get("type") == "TABS":
+            # Found a TABS component; use its first TAB child
+            tabs_children = child.get("children", [])
+            if tabs_children:
+                first_tab_id = tabs_children[0]
+                first_tab = layout.get(first_tab_id)
+                if first_tab and first_tab.get("type") == "TAB":
+                    return first_tab_id
+    return None
 
 
 def _add_chart_to_layout(
-    layout: Dict[str, Any], chart: Any, chart_id: int, row_index: int
-) -> tuple[str, str]:
+    layout: Dict[str, Any],
+    chart: Any,
+    chart_id: int,
+    row_key: str,
+    parent_id: str,
+) -> tuple[str, str, str]:
     """
-    Add chart and row components to the dashboard layout.
+    Add chart, column, and row components to the dashboard layout.
+
+    Creates the proper ``ROW > COLUMN > CHART`` hierarchy that the
+    frontend expects for rendering.
+
+    Args:
+        layout: The mutable layout dict to update.
+        chart: The chart ORM object.
+        chart_id: The chart's integer ID.
+        row_key: The pre-generated ROW component ID.
+        parent_id: The parent container ID (GRID_ID or a TAB ID).
 
     Returns:
-        Tuple of (chart_key, row_key)
+        Tuple of ``(chart_key, column_key, row_key)``.
     """
     chart_key = f"CHART-{chart_id}"
-    row_key = f"ROW-{row_index}"
-    chart_width = 5  # Balanced width for good proportions
+    column_key = generate_id("COLUMN")
+    chart_width = GRID_DEFAULT_CHART_WIDTH
     chart_height = 50  # Good height for most chart types
 
-    # Add chart to layout using proper Superset structure
+    # Build the parents chain up to the parent container
+    if (parent_component := layout.get(parent_id)) is not None:
+        parent_parents = parent_component.get("parents", [])
+    elif parent_id == "GRID_ID":
+        # Empty layout: GRID_ID will be created by _ensure_layout_structure
+        # with parents=["ROOT_ID"], so mirror that here.
+        parent_parents = ["ROOT_ID"]
+    else:
+        parent_parents = []
+    row_parents = list(parent_parents) + [parent_id]
+    column_parents = row_parents + [row_key]
+    chart_parents = column_parents + [column_key]
+
+    # Add chart component
     layout[chart_key] = {
         "children": [],
         "id": chart_key,
@@ -82,25 +144,45 @@ def _add_chart_to_layout(
             "uuid": str(chart.uuid) if chart.uuid else f"chart-{chart_id}",
             "width": chart_width,
         },
-        "parents": ["ROOT_ID", "GRID_ID", row_key],
+        "parents": chart_parents,
         "type": "CHART",
     }
 
-    # Create row for the chart
-    layout[row_key] = {
+    # Add column wrapper (ROW > COLUMN > CHART)
+    layout[column_key] = {
         "children": [chart_key],
+        "id": column_key,
+        "meta": {
+            "background": "BACKGROUND_TRANSPARENT",
+            "width": GRID_COLUMN_COUNT,
+        },
+        "parents": column_parents,
+        "type": "COLUMN",
+    }
+
+    # Create row containing the column
+    layout[row_key] = {
+        "children": [column_key],
         "id": row_key,
         "meta": {"background": "BACKGROUND_TRANSPARENT"},
-        "parents": ["ROOT_ID", "GRID_ID"],
+        "parents": row_parents,
         "type": "ROW",
     }
 
-    return chart_key, row_key
+    return chart_key, column_key, row_key
 
 
-def _ensure_layout_structure(layout: Dict[str, Any], row_key: str) -> None:
+def _ensure_layout_structure(
+    layout: Dict[str, Any], row_key: str, parent_id: str
+) -> None:
     """
-    Ensure the dashboard layout has proper GRID and ROOT structure.
+    Ensure the dashboard layout has proper GRID and ROOT structure,
+    and add the new row to the correct parent container.
+
+    Args:
+        layout: The mutable layout dict to update.
+        row_key: The ROW component ID to insert.
+        parent_id: The container to add the row to (GRID_ID or a TAB ID).
     """
     # Ensure GRID structure exists
     if "GRID_ID" not in layout:
@@ -111,10 +193,16 @@ def _ensure_layout_structure(layout: Dict[str, Any], 
row_key: str) -> None:
             "type": "GRID",
         }
 
-    # Add row to GRID
-    if "children" not in layout["GRID_ID"]:
-        layout["GRID_ID"]["children"] = []
-    layout["GRID_ID"]["children"].append(row_key)
+    # Add row to the target parent container
+    if parent := layout.get(parent_id):
+        if "children" not in parent:
+            parent["children"] = []
+        parent["children"].append(row_key)
+    else:
+        # Fallback: add to GRID_ID
+        if "children" not in layout["GRID_ID"]:
+            layout["GRID_ID"]["children"] = []
+        layout["GRID_ID"]["children"].append(row_key)
 
     # Update ROOT_ID if it exists, or create it
     if "ROOT_ID" in layout:
@@ -193,16 +281,20 @@ def add_chart_to_existing_dashboard(
             except (json.JSONDecodeError, TypeError):
                 current_layout = {}
 
-            # Find position for new chart
-            row_index = _find_next_row_position(current_layout)
+            # Generate a unique ROW ID for the new row
+            row_key = _find_next_row_position(current_layout)
+
+            # Detect tabbed dashboards: if GRID has TABS, target the first tab
+            tab_target = _find_tab_insert_target(current_layout)
+            parent_id = tab_target if tab_target else "GRID_ID"
 
-            # Add chart and row to layout
-            chart_key, row_key = _add_chart_to_layout(
-                current_layout, new_chart, request.chart_id, row_index
+            # Add chart, column, and row to layout
+            chart_key, column_key, row_key = _add_chart_to_layout(
+                current_layout, new_chart, request.chart_id, row_key, parent_id
             )
 
             # Ensure proper layout structure
-            _ensure_layout_structure(current_layout, row_key)
+            _ensure_layout_structure(current_layout, row_key, parent_id)
 
         # Update the dashboard
         with 
event_logger.log_context(action="mcp.add_chart_to_dashboard.db_write"):
@@ -268,7 +360,7 @@ def add_chart_to_existing_dashboard(
         )
 
         # Return position info for compatibility
-        position_info = {"row": row_index, "chart_key": chart_key, "row_key": 
row_key}
+        position_info = {"row": row_key, "chart_key": chart_key, "row_key": 
row_key}
 
         return AddChartToDashboardResponse(
             dashboard=dashboard_info,
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py 
b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index 5d537ced2af..29cfb61eef0 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -28,6 +28,11 @@ from fastmcp import Context
 from superset_core.mcp import tool
 
 from superset.extensions import event_logger
+from superset.mcp_service.dashboard.constants import (
+    generate_id,
+    GRID_COLUMN_COUNT,
+    GRID_DEFAULT_CHART_WIDTH,
+)
 from superset.mcp_service.dashboard.schemas import (
     DashboardInfo,
     GenerateDashboardRequest,
@@ -44,8 +49,8 @@ def _create_dashboard_layout(chart_objects: List[Any]) -> 
Dict[str, Any]:
     """
     Create a simple dashboard layout with charts arranged in a grid.
 
-    This creates a basic 2-column layout where charts are arranged
-    vertically in alternating columns.
+    This creates a ``ROW > COLUMN > CHART`` hierarchy for each row,
+    matching the component structure that the Superset frontend expects.
 
     Args:
         chart_objects: List of Chart ORM objects (not IDs)
@@ -55,23 +60,26 @@ def _create_dashboard_layout(chart_objects: List[Any]) -> 
Dict[str, Any]:
     # Grid configuration based on real Superset dashboard patterns
     # Use 2-chart rows with medium-sized charts (like existing dashboards)
     charts_per_row = 2
-    chart_width = 5  # Balanced width for good proportions
+    chart_width = GRID_DEFAULT_CHART_WIDTH
     chart_height = 50  # Good height for most chart types
 
-    # Create rows with charts
+    # Create rows with charts wrapped in columns
     row_ids = []
     for i in range(0, len(chart_objects), charts_per_row):
-        row_index = i // charts_per_row
-        row_id = f"ROW-{row_index}"
+        row_id = generate_id("ROW")
         row_ids.append(row_id)
 
         # Get charts for this row (up to 2 charts like real dashboards)
         row_charts = chart_objects[i : i + charts_per_row]
-        chart_keys = []
+        column_keys = []
+
+        # Calculate column width: divide grid evenly among charts in this row
+        col_width = GRID_COLUMN_COUNT // len(row_charts)
 
         for chart in row_charts:
             chart_key = f"CHART-{chart.id}"
-            chart_keys.append(chart_key)
+            column_key = generate_id("COLUMN")
+            column_keys.append(column_key)
 
             # Create chart component with standard dimensions
             layout[chart_key] = {
@@ -84,13 +92,25 @@ def _create_dashboard_layout(chart_objects: List[Any]) -> 
Dict[str, Any]:
                     "uuid": str(chart.uuid) if chart.uuid else 
f"chart-{chart.id}",
                     "width": chart_width,
                 },
-                "parents": ["ROOT_ID", "GRID_ID", row_id],
+                "parents": ["ROOT_ID", "GRID_ID", row_id, column_key],
                 "type": "CHART",
             }
 
-        # Create row containing the charts
+            # Create column wrapper for the chart (ROW > COLUMN > CHART)
+            layout[column_key] = {
+                "children": [chart_key],
+                "id": column_key,
+                "meta": {
+                    "background": "BACKGROUND_TRANSPARENT",
+                    "width": col_width,
+                },
+                "parents": ["ROOT_ID", "GRID_ID", row_id],
+                "type": "COLUMN",
+            }
+
+        # Create row containing the columns
         layout[row_id] = {
-            "children": chart_keys,
+            "children": column_keys,
             "id": row_id,
             "meta": {"background": "BACKGROUND_TRANSPARENT"},
             "parents": ["ROOT_ID", "GRID_ID"],
diff --git 
a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py 
b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
index 32d9dda92ee..83fe00df0fb 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
@@ -26,6 +26,13 @@ import pytest
 from fastmcp import Client
 
 from superset.mcp_service.app import mcp
+from superset.mcp_service.dashboard.constants import generate_id
+from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard 
import (
+    _add_chart_to_layout,
+    _ensure_layout_structure,
+    _find_next_row_position,
+    _find_tab_insert_target,
+)
 from superset.utils import json
 
 logging.basicConfig(level=logging.DEBUG)
@@ -73,8 +80,8 @@ def _mock_dashboard(id: int = 1, title: str = "Test 
Dashboard") -> Mock:
     dashboard.changed_by.username = "test_user"
     dashboard.uuid = f"dashboard-uuid-{id}"
     dashboard.slices = []
-    dashboard.owners = []  # Add missing owners attribute
-    dashboard.tags = []  # Add missing tags attribute
+    dashboard.owners = []
+    dashboard.tags = []
     return dashboard
 
 
@@ -88,7 +95,6 @@ class TestGenerateDashboard:
         self, mock_db_session, mock_create_command, mcp_server
     ):
         """Test basic dashboard generation with valid charts."""
-        # Mock database query for charts
         mock_query = Mock()
         mock_filter = Mock()
         mock_query.filter.return_value = mock_filter
@@ -98,7 +104,6 @@ class TestGenerateDashboard:
         ]
         mock_db_session.query.return_value = mock_query
 
-        # Mock dashboard creation
         mock_dashboard = _mock_dashboard(id=10, title="Analytics Dashboard")
         mock_create_command.return_value.run.return_value = mock_dashboard
 
@@ -128,14 +133,10 @@ class TestGenerateDashboard:
     @pytest.mark.asyncio
     async def test_generate_dashboard_missing_charts(self, mock_db_session, 
mcp_server):
         """Test error handling when some charts don't exist."""
-        # Mock database query returning only chart 1 (chart 2 missing)
         mock_query = Mock()
         mock_filter = Mock()
         mock_query.filter.return_value = mock_filter
-        mock_filter.all.return_value = [
-            _mock_chart(id=1),
-            # Chart 2 is missing from the result
-        ]
+        mock_filter.all.return_value = [_mock_chart(id=1)]
         mock_db_session.query.return_value = mock_query
 
         request = {"chart_ids": [1, 2], "dashboard_title": "Test Dashboard"}
@@ -155,7 +156,6 @@ class TestGenerateDashboard:
         self, mock_db_session, mock_create_command, mcp_server
     ):
         """Test dashboard generation with a single chart."""
-        # Mock database query for single chart
         mock_query = Mock()
         mock_filter = Mock()
         mock_query.filter.return_value = mock_filter
@@ -185,7 +185,6 @@ class TestGenerateDashboard:
         self, mock_db_session, mock_create_command, mcp_server
     ):
         """Test dashboard generation with many charts (grid layout)."""
-        # Mock 6 charts
         chart_ids = list(range(1, 7))
         mock_query = Mock()
         mock_filter = Mock()
@@ -206,42 +205,48 @@ class TestGenerateDashboard:
             assert result.structured_content["error"] is None
             assert result.structured_content["dashboard"]["chart_count"] == 6
 
-            # Verify CreateDashboardCommand was called with proper layout
             mock_create_command.assert_called_once()
             call_args = mock_create_command.call_args[0][0]
 
-            # Check position_json contains proper layout
             position_json = json.loads(call_args["position_json"])
             assert "ROOT_ID" in position_json
             assert "GRID_ID" in position_json
             assert "DASHBOARD_VERSION_KEY" in position_json
             assert position_json["DASHBOARD_VERSION_KEY"] == "v2"
 
-            # ROOT should only contain GRID
             assert position_json["ROOT_ID"]["children"] == ["GRID_ID"]
 
-            # GRID should contain rows (6 charts = 3 rows in 2-chart layout)
             grid_children = position_json["GRID_ID"]["children"]
             assert len(grid_children) == 3
 
-            # Check each chart has proper structure
-            for i, chart_id in enumerate(chart_ids):
+            for row_id in grid_children:
+                assert row_id.startswith("ROW-")
+
+            for chart_id in chart_ids:
                 chart_key = f"CHART-{chart_id}"
-                row_index = i // 2  # 2 charts per row
-                row_key = f"ROW-{row_index}"
 
-                # Chart should exist
                 assert chart_key in position_json
                 chart_data = position_json[chart_key]
                 assert chart_data["type"] == "CHART"
                 assert "meta" in chart_data
                 assert chart_data["meta"]["chartId"] == chart_id
-
-                # Row should exist and contain charts (up to 2 per row)
+                assert chart_data["meta"]["width"] == 4
+
+                chart_parents = chart_data["parents"]
+                column_key = chart_parents[-1]
+                assert column_key.startswith("COLUMN-")
+                assert column_key in position_json
+                column_data = position_json[column_key]
+                assert column_data["type"] == "COLUMN"
+                assert chart_key in column_data["children"]
+
+                column_parents = column_data["parents"]
+                row_key = column_parents[-1]
+                assert row_key.startswith("ROW-")
                 assert row_key in position_json
                 row_data = position_json[row_key]
                 assert row_data["type"] == "ROW"
-                assert chart_key in row_data["children"]
+                assert column_key in row_data["children"]
 
     @patch("superset.commands.dashboard.create.CreateDashboardCommand")
     @patch("superset.db.session")
@@ -273,7 +278,6 @@ class TestGenerateDashboard:
         self, mock_db_session, mock_create_command, mcp_server
     ):
         """Test dashboard generation with minimal required parameters."""
-        # Mock database query for single chart
         mock_query = Mock()
         mock_filter = Mock()
         mock_query.filter.return_value = mock_filter
@@ -286,7 +290,6 @@ class TestGenerateDashboard:
         request = {
             "chart_ids": [3],
             "dashboard_title": "Minimal Dashboard",
-            # No description, published defaults to True
         }
 
         async with Client(mcp_server) as client:
@@ -298,9 +301,8 @@ class TestGenerateDashboard:
                 == "Minimal Dashboard"
             )
 
-            # Check that description was not included in call
             call_args = mock_create_command.call_args[0][0]
-            assert call_args["published"] is True  # Default value
+            assert call_args["published"] is True
             assert (
                 "description" not in call_args or call_args.get("description") 
is None
             )
@@ -317,29 +319,46 @@ class TestAddChartToExistingDashboard:
         self, mock_db_session, mock_find_dashboard, mock_update_command, 
mcp_server
     ):
         """Test adding a chart to an existing dashboard."""
-        # Mock existing dashboard with some charts
         mock_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
-        mock_dashboard.slices = [Mock(id=10), Mock(id=20)]  # Existing charts
+        mock_dashboard.slices = [Mock(id=10), Mock(id=20)]
         mock_dashboard.position_json = json.dumps(
             {
                 "ROOT_ID": {
-                    "children": ["CHART-10", "CHART-20"],
+                    "children": ["GRID_ID"],
                     "id": "ROOT_ID",
                     "type": "ROOT",
                 },
-                "CHART-10": {"id": "CHART-10", "type": "CHART", "parents": 
["ROOT_ID"]},
-                "CHART-10_POSITION": {"h": 16, "w": 24, "x": 0, "y": 0},
-                "CHART-20": {"id": "CHART-20", "type": "CHART", "parents": 
["ROOT_ID"]},
-                "CHART-20_POSITION": {"h": 16, "w": 24, "x": 24, "y": 0},
+                "GRID_ID": {
+                    "children": ["ROW-abc123"],
+                    "id": "GRID_ID",
+                    "parents": ["ROOT_ID"],
+                    "type": "GRID",
+                },
+                "ROW-abc123": {
+                    "children": ["CHART-10", "CHART-20"],
+                    "id": "ROW-abc123",
+                    "meta": {"background": "BACKGROUND_TRANSPARENT"},
+                    "parents": ["ROOT_ID", "GRID_ID"],
+                    "type": "ROW",
+                },
+                "CHART-10": {
+                    "id": "CHART-10",
+                    "type": "CHART",
+                    "parents": ["ROOT_ID", "GRID_ID", "ROW-abc123"],
+                },
+                "CHART-20": {
+                    "id": "CHART-20",
+                    "type": "CHART",
+                    "parents": ["ROOT_ID", "GRID_ID", "ROW-abc123"],
+                },
+                "DASHBOARD_VERSION_KEY": "v2",
             }
         )
         mock_find_dashboard.return_value = mock_dashboard
 
-        # Mock chart to add
         mock_chart = _mock_chart(id=30, slice_name="New Chart")
         mock_db_session.get.return_value = mock_chart
 
-        # Mock updated dashboard
         updated_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
         updated_dashboard.slices = [Mock(id=10), Mock(id=20), Mock(id=30)]
         mock_update_command.return_value.run.return_value = updated_dashboard
@@ -357,23 +376,34 @@ class TestAddChartToExistingDashboard:
             assert result.structured_content["position"] is not None
             assert "row" in result.structured_content["position"]
             assert "chart_key" in result.structured_content["position"]
+            row_key = result.structured_content["position"]["row_key"]
+            assert row_key.startswith("ROW-")
             assert (
                 "/superset/dashboard/1/" in 
result.structured_content["dashboard_url"]
             )
 
+            call_args = mock_update_command.call_args[0][1]
+            layout = json.loads(call_args["position_json"])
+            assert "CHART-30" in layout
+            chart_data = layout["CHART-30"]
+            assert chart_data["type"] == "CHART"
+            chart_parents = chart_data["parents"]
+            column_key = chart_parents[-1]
+            assert column_key.startswith("COLUMN-")
+            assert column_key in layout
+            assert layout[column_key]["type"] == "COLUMN"
+
     @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
     @pytest.mark.asyncio
     async def test_add_chart_dashboard_not_found(self, mock_find_dashboard, 
mcp_server):
         """Test error when dashboard doesn't exist."""
         mock_find_dashboard.return_value = None
-
         request = {"dashboard_id": 999, "chart_id": 1}
 
         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
             assert (
                 "Dashboard with ID 999 not found" in 
result.structured_content["error"]
@@ -388,14 +418,12 @@ class TestAddChartToExistingDashboard:
         """Test error when chart doesn't exist."""
         mock_find_dashboard.return_value = _mock_dashboard()
         mock_db_session.get.return_value = None
-
         request = {"dashboard_id": 1, "chart_id": 999}
 
         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
             assert "Chart with ID 999 not found" in 
result.structured_content["error"]
 
@@ -407,18 +435,15 @@ class TestAddChartToExistingDashboard:
     ):
         """Test error when chart is already in dashboard."""
         mock_dashboard = _mock_dashboard()
-        mock_dashboard.slices = [Mock(id=5)]  # Chart 5 already exists
+        mock_dashboard.slices = [Mock(id=5)]
         mock_find_dashboard.return_value = mock_dashboard
-
         mock_db_session.get.return_value = _mock_chart(id=5)
-
         request = {"dashboard_id": 1, "chart_id": 5}
 
         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
             assert (
                 "Chart 5 is already in dashboard 1"
@@ -435,7 +460,7 @@ class TestAddChartToExistingDashboard:
         """Test adding chart to dashboard with no existing layout."""
         mock_dashboard = _mock_dashboard(id=2)
         mock_dashboard.slices = []
-        mock_dashboard.position_json = "{}"  # Empty layout
+        mock_dashboard.position_json = "{}"
         mock_find_dashboard.return_value = mock_dashboard
 
         mock_chart = _mock_chart(id=15)
@@ -454,12 +479,290 @@ class TestAddChartToExistingDashboard:
 
             assert result.structured_content["error"] is None
             assert "row" in result.structured_content["position"]
-            assert result.structured_content["position"].get("row") == 0
+            row_key = result.structured_content["position"]["row"]
+            assert isinstance(row_key, str)
+            assert row_key.startswith("ROW-")
 
-            # Verify update was called with proper layout structure
             call_args = mock_update_command.call_args[0][1]
             layout = json.loads(call_args["position_json"])
             assert "ROOT_ID" in layout
             assert "GRID_ID" in layout
-            assert "ROW-0" in layout
             assert "CHART-15" in layout
+
+            assert row_key in layout
+            row_data = layout[row_key]
+            assert row_data["type"] == "ROW"
+            assert len(row_data["children"]) == 1
+
+            column_key = row_data["children"][0]
+            assert column_key.startswith("COLUMN-")
+            assert column_key in layout
+            column_data = layout[column_key]
+            assert column_data["type"] == "COLUMN"
+            assert "CHART-15" in column_data["children"]
+
+            # Verify ROOT_ID is in parents chain even for empty layouts
+            chart_parents = layout["CHART-15"]["parents"]
+            assert "ROOT_ID" in chart_parents
+            assert "GRID_ID" 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_to_tabbed_dashboard(
+        self, mock_db_session, mock_find_dashboard, mock_update_command, 
mcp_server
+    ):
+        """Test adding chart to a dashboard that uses tabs."""
+        mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard")
+        mock_dashboard.slices = [Mock(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": ["ROW-existing"],
+                    "id": "TAB-tab1",
+                    "meta": {"text": "Tab 1"},
+                    "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
+                    "type": "TAB",
+                },
+                "TAB-tab2": {
+                    "children": [],
+                    "id": "TAB-tab2",
+                    "meta": {"text": "Tab 2"},
+                    "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
+                    "type": "TAB",
+                },
+                "ROW-existing": {
+                    "children": ["CHART-10"],
+                    "id": "ROW-existing",
+                    "meta": {"background": "BACKGROUND_TRANSPARENT"},
+                    "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123", 
"TAB-tab1"],
+                    "type": "ROW",
+                },
+                "CHART-10": {
+                    "id": "CHART-10",
+                    "type": "CHART",
+                    "parents": [
+                        "ROOT_ID",
+                        "GRID_ID",
+                        "TABS-abc123",
+                        "TAB-tab1",
+                        "ROW-existing",
+                    ],
+                },
+                "DASHBOARD_VERSION_KEY": "v2",
+            }
+        )
+        mock_find_dashboard.return_value = mock_dashboard
+
+        mock_chart = _mock_chart(id=25, slice_name="Tab Chart")
+        mock_db_session.get.return_value = mock_chart
+
+        updated_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard")
+        updated_dashboard.slices = [Mock(id=10), Mock(id=25)]
+        mock_update_command.return_value.run.return_value = updated_dashboard
+
+        request = {"dashboard_id": 3, "chart_id": 25}
+
+        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 None
+
+            call_args = mock_update_command.call_args[0][1]
+            layout = json.loads(call_args["position_json"])
+
+            row_key = result.structured_content["position"]["row_key"]
+            assert row_key in layout
+            assert row_key in layout["TAB-tab1"]["children"]
+            assert layout["GRID_ID"]["children"] == ["TABS-abc123"]
+
+            row_data = layout[row_key]
+            assert row_data["type"] == "ROW"
+            column_key = row_data["children"][0]
+            assert layout[column_key]["type"] == "COLUMN"
+            assert "CHART-25" in layout[column_key]["children"]
+
+            chart_parents = layout["CHART-25"]["parents"]
+            assert "TABS-abc123" in chart_parents
+            assert "TAB-tab1" 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_dashboard_with_nanoid_rows(
+        self, mock_db_session, mock_find_dashboard, mock_update_command, 
mcp_server
+    ):
+        """Test adding chart to dashboard that has nanoid-style ROW IDs."""
+        mock_dashboard = _mock_dashboard(id=4, title="Nanoid Dashboard")
+        mock_dashboard.slices = [Mock(id=10)]
+        mock_dashboard.position_json = json.dumps(
+            {
+                "ROOT_ID": {
+                    "children": ["GRID_ID"],
+                    "id": "ROOT_ID",
+                    "type": "ROOT",
+                },
+                "GRID_ID": {
+                    "children": ["ROW-46632bc2"],
+                    "id": "GRID_ID",
+                    "parents": ["ROOT_ID"],
+                    "type": "GRID",
+                },
+                "ROW-46632bc2": {
+                    "children": ["CHART-10"],
+                    "id": "ROW-46632bc2",
+                    "meta": {"background": "BACKGROUND_TRANSPARENT"},
+                    "parents": ["ROOT_ID", "GRID_ID"],
+                    "type": "ROW",
+                },
+                "CHART-10": {
+                    "id": "CHART-10",
+                    "type": "CHART",
+                    "parents": ["ROOT_ID", "GRID_ID", "ROW-46632bc2"],
+                },
+                "DASHBOARD_VERSION_KEY": "v2",
+            }
+        )
+        mock_find_dashboard.return_value = mock_dashboard
+
+        mock_chart = _mock_chart(id=50, slice_name="New Nanoid Chart")
+        mock_db_session.get.return_value = mock_chart
+
+        updated_dashboard = _mock_dashboard(id=4, title="Nanoid Dashboard")
+        updated_dashboard.slices = [Mock(id=10), Mock(id=50)]
+        mock_update_command.return_value.run.return_value = updated_dashboard
+
+        request = {"dashboard_id": 4, "chart_id": 50}
+
+        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 None
+
+            call_args = mock_update_command.call_args[0][1]
+            layout = json.loads(call_args["position_json"])
+
+            row_key = result.structured_content["position"]["row_key"]
+            assert row_key != "ROW-46632bc2"
+            assert row_key.startswith("ROW-")
+            assert row_key in layout
+
+
+class TestLayoutHelpers:
+    """Tests for layout helper functions."""
+
+    def test_generate_id_format(self):
+        """Test that _generate_id produces correct format."""
+        row_id = generate_id("ROW")
+        assert row_id.startswith("ROW-")
+        assert len(row_id) == 12
+
+        col_id = generate_id("COLUMN")
+        assert col_id.startswith("COLUMN-")
+        assert len(col_id) == 15
+
+    def test_generate_id_uniqueness(self):
+        """Test that _generate_id produces unique IDs."""
+        ids = {generate_id("ROW") for _ in range(100)}
+        assert len(ids) == 100
+
+    def test_find_next_row_position_empty_layout(self):
+        """Test _find_next_row_position with empty layout."""
+        result = _find_next_row_position({})
+        assert isinstance(result, str)
+        assert result.startswith("ROW-")
+
+    def test_find_tab_insert_target_no_tabs(self):
+        """Test _find_tab_insert_target with no tabs."""
+        layout = {"GRID_ID": {"children": ["ROW-1"], "type": "GRID"}}
+        assert _find_tab_insert_target(layout) is None
+
+    def test_find_tab_insert_target_with_tabs(self):
+        """Test _find_tab_insert_target with tabbed dashboard."""
+        layout = {
+            "GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
+            "TABS-main": {"children": ["TAB-first", "TAB-second"], "type": 
"TABS"},
+            "TAB-first": {"children": [], "type": "TAB"},
+            "TAB-second": {"children": [], "type": "TAB"},
+        }
+        assert _find_tab_insert_target(layout) == "TAB-first"
+
+    def test_find_tab_insert_target_no_grid(self):
+        """Test _find_tab_insert_target with missing GRID_ID."""
+        assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
+
+    def test_add_chart_to_layout_creates_column(self):
+        """Test that _add_chart_to_layout creates ROW > COLUMN > CHART."""
+        layout = {
+            "GRID_ID": {"children": [], "parents": ["ROOT_ID"], "type": 
"GRID"},
+        }
+        chart = Mock()
+        chart.slice_name = "Test Chart"
+        chart.uuid = "test-uuid"
+
+        chart_key, column_key, row_key = _add_chart_to_layout(
+            layout, chart, 42, "ROW-test123", "GRID_ID"
+        )
+
+        assert chart_key == "CHART-42"
+        assert column_key.startswith("COLUMN-")
+        assert row_key == "ROW-test123"
+
+        assert layout[row_key]["type"] == "ROW"
+        assert layout[row_key]["children"] == [column_key]
+        assert layout[column_key]["type"] == "COLUMN"
+        assert layout[column_key]["children"] == [chart_key]
+        assert layout[column_key]["meta"]["width"] == 12
+        assert layout[chart_key]["type"] == "CHART"
+        assert layout[chart_key]["meta"]["width"] == 4
+        assert layout[chart_key]["meta"]["chartId"] == 42
+
+    def test_ensure_layout_structure_creates_missing(self):
+        """Test _ensure_layout_structure creates GRID and ROOT if missing."""
+        layout: dict = {}
+        _ensure_layout_structure(layout, "ROW-test", "GRID_ID")
+
+        assert "ROOT_ID" in layout
+        assert "GRID_ID" in layout
+        assert "GRID_ID" in layout["ROOT_ID"]["children"]
+        assert "ROW-test" in layout["GRID_ID"]["children"]
+        assert layout["DASHBOARD_VERSION_KEY"] == "v2"
+
+    def test_ensure_layout_structure_adds_to_tab(self):
+        """Test _ensure_layout_structure adds row to tab parent."""
+        layout = {
+            "ROOT_ID": {"children": ["GRID_ID"], "type": "ROOT"},
+            "GRID_ID": {
+                "children": ["TABS-main"],
+                "parents": ["ROOT_ID"],
+                "type": "GRID",
+            },
+            "TAB-first": {"children": ["ROW-existing"], "type": "TAB"},
+        }
+        _ensure_layout_structure(layout, "ROW-new", "TAB-first")
+
+        assert "ROW-new" in layout["TAB-first"]["children"]
+        assert "ROW-new" not in layout["GRID_ID"]["children"]


Reply via email to