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: 35 additions & 1 deletion ddpui/api/dashboard_native_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,43 @@ def duplicate_dashboard(request, dashboard_id: int):

updated_components[new_component_id] = new_component_data

# Update the dashboard with the corrected layout_config and components
# Copy tabs and update filter IDs inside each tab
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont understand this. once migration is done isn't this will be just dead code?

The migration command clears root-level layout_config/components and moves everything into tabs, but the frontend save still sends both . Can we make
tabs the single source of truth and stop using root-level fields for tabbed dashboards

new_tabs = []
for tab in copy.deepcopy(original_dashboard.tabs or []):
# Update filter IDs in tab's layout_config
for layout_item in tab.get("layout_config", []):
item_id = layout_item.get("i", "")
if item_id.startswith("filter-"):
old_filter_id = item_id.replace("filter-", "")
if old_filter_id in filter_id_mapping:
layout_item["i"] = f"filter-{filter_id_mapping[old_filter_id]}"

# Update filter IDs in tab's components
updated_tab_components = {}
for component_id, component_data in tab.get("components", {}).items():
new_component_id = component_id
new_component_data = copy.deepcopy(component_data)

if component_id.startswith("filter-"):
old_filter_id = component_id.replace("filter-", "")
if old_filter_id in filter_id_mapping:
new_filter_id = filter_id_mapping[old_filter_id]
new_component_id = f"filter-{new_filter_id}"
if (
"config" in new_component_data
and "filterId" in new_component_data["config"]
):
new_component_data["config"]["filterId"] = int(new_filter_id)

updated_tab_components[new_component_id] = new_component_data

tab["components"] = updated_tab_components
new_tabs.append(tab)

# Update the dashboard with the corrected layout_config, components, and tabs
new_dashboard.layout_config = new_layout_config
new_dashboard.components = updated_components
new_dashboard.tabs = new_tabs
new_dashboard.save()

logger.info(
Expand Down
95 changes: 95 additions & 0 deletions ddpui/management/commands/migrate_dashboards_to_tabs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import time
from django.core.management.base import BaseCommand
from ddpui.models.dashboard import Dashboard

Comment thread
coderabbitai[bot] marked this conversation as resolved.

class Command(BaseCommand):
"""
Migrate existing dashboards to use tabs structure.
Moves layout_config and components from root level into a default tab.
"""

help = "Migrate existing dashboards to tabs structure for all orgs"

def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without applying them",
)

def handle(self, *args, **options):
dry_run = options["dry_run"]

if dry_run:
self.stdout.write("\n=== DRY RUN MODE: No changes will be made ===\n")

migrated_count = 0
skipped_count = 0

for dashboard in Dashboard.objects.only(
"id", "title", "tabs", "layout_config", "components", "org"
).iterator(chunk_size=1000):
# Skip if already has tabs
if dashboard.tabs and len(dashboard.tabs) > 0:
skipped_count += 1
continue

# Skip if no data to migrate
has_layout = dashboard.layout_config and len(dashboard.layout_config) > 0
has_components = dashboard.components and len(dashboard.components) > 0

if not has_layout and not has_components:
skipped_count += 1
continue

# This dashboard needs migration
migrated_count += 1
org_id = getattr(dashboard, "org_id", "N/A")

if dry_run:
self.stdout.write(
f"[DRY RUN] Would migrate - "
f"Dashboard ID: {dashboard.id}, "
f"Title: {dashboard.title}, "
f"Org ID: {org_id}, "
f"Charts: {len(dashboard.layout_config or [])}"
)
else:
# Create default tab with existing data
default_tab = {
"id": f"tab-{int(time.time() * 1000)}",
"title": "Untitled Tab 1",
"layout_config": dashboard.layout_config or [],
"components": dashboard.components or {},
}

# Update dashboard structure
dashboard.tabs = [default_tab]
dashboard.layout_config = []
dashboard.components = {}
dashboard.save(update_fields=["tabs", "layout_config", "components"])

self.stdout.write(
f"Migrated - "
f"Dashboard ID: {dashboard.id}, "
f"Title: {dashboard.title}, "
f"Org ID: {org_id}, "
f"Charts: {len(default_tab['layout_config'])}"
)

self.stdout.write("")
if dry_run:
self.stdout.write(
self.style.WARNING(
f"=== DRY RUN COMPLETE: {migrated_count} dashboards would be migrated, "
f"{skipped_count} skipped ==="
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f"=== MIGRATION COMPLETE: {migrated_count} dashboards migrated, "
f"{skipped_count} skipped ==="
)
)
20 changes: 20 additions & 0 deletions ddpui/migrations/0160_add_dashboard_tabs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2 on 2026-03-25 20:32

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("ddpui", "0156_add_comment_snapshot_index"),
]

operations = [
migrations.AddField(
model_name="dashboard",
name="tabs",
field=models.JSONField(
default=list,
help_text="Array of tab objects: [{id, title, layout_config, components}]",
),
),
]
6 changes: 6 additions & 0 deletions ddpui/models/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ class Dashboard(models.Model):
# Components configuration
components = models.JSONField(default=dict, help_text="Dashboard components configuration")

# Tabs configuration - each tab contains its own layout_config and components
tabs = models.JSONField(
default=list, help_text="Array of tab objects: [{id, title, layout_config, components}]"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Filter layout configuration
filter_layout = models.CharField(
max_length=20,
Expand Down Expand Up @@ -134,6 +139,7 @@ def to_json(self):
"target_screen_size": self.target_screen_size,
"layout_config": self.layout_config,
"components": self.components,
"tabs": self.tabs or [],
"filter_layout": self.filter_layout,
"is_published": self.is_published,
"published_at": self.published_at.isoformat() if self.published_at else None,
Expand Down
15 changes: 15 additions & 0 deletions ddpui/schemas/dashboard_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ class DashboardCreate(Schema):
grid_columns: int = 12


class DashboardTabSchema(Schema):
"""Schema for a dashboard tab

Each tab contains its own layout and components, allowing users
to organize charts into separate views within a single dashboard.
"""

id: str # e.g., "tab-1710901234567"
title: str # e.g., "Untitled Tab 1" (max 50 chars)
layout_config: List[dict] = [] # Grid positions for this tab
components: dict = {} # Component configs for this tab


class DashboardUpdate(Schema):
"""Schema for updating a dashboard"""

Expand All @@ -31,6 +44,7 @@ class DashboardUpdate(Schema):
target_screen_size: Optional[str] = None
layout_config: Optional[list[dict]] = None
components: Optional[dict] = None
tabs: Optional[List[DashboardTabSchema]] = None # Array of tabs
filter_layout: Optional[str] = None
is_published: Optional[bool] = None

Expand Down Expand Up @@ -63,6 +77,7 @@ class DashboardResponse(Schema):
filter_layout: str
layout_config: list[dict]
components: dict
tabs: List[DashboardTabSchema] = [] # Array of tabs
Comment thread
coderabbitai[bot] marked this conversation as resolved.
is_published: bool
published_at: Optional[datetime] = None
is_locked: bool = False
Expand Down
13 changes: 13 additions & 0 deletions ddpui/services/dashboard_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from datetime import datetime, timedelta
from dataclasses import dataclass
import json
import time
import uuid

from django.core.cache import cache
Expand Down Expand Up @@ -360,10 +361,19 @@ def create_dashboard(data: DashboardData, orguser: OrgUser) -> Dashboard:
Returns:
Created Dashboard instance
"""
# Generate default tab for new dashboard
default_tab = {
"id": f"tab-{int(time.time() * 1000)}",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need this? dashboard id will be different right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the Dashboard id is different from the tab id.
Dashboard id identifies the dashboard, and Tab id identifies
which tab within that dashboard. A dashboard can have multiple tabs, so we need
a way to reference each one.

"title": "Untitled Tab 1",
"layout_config": [],
"components": {},
}

dashboard = Dashboard.objects.create(
title=data.title,
description=data.description,
grid_columns=data.grid_columns,
tabs=[default_tab], # Initialize with default tab
created_by=orguser,
org=orguser.org,
last_modified_by=orguser,
Expand Down Expand Up @@ -414,6 +424,9 @@ def update_dashboard(
dashboard.layout_config = data.layout_config
if data.components is not None:
dashboard.components = data.components
if data.tabs is not None:
# Convert Pydantic models to dicts for JSON storage
dashboard.tabs = [tab.dict() for tab in data.tabs]
if data.filter_layout is not None:
dashboard.filter_layout = data.filter_layout
if data.is_published is not None:
Expand Down
90 changes: 90 additions & 0 deletions ddpui/tests/api_tests/test_dashboard_native_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
create_dashboard,
update_dashboard,
delete_dashboard,
duplicate_dashboard,
create_filter,
update_filter,
delete_filter,
Expand Down Expand Up @@ -593,6 +594,95 @@ def test_delete_filter_not_found(self, orguser, sample_dashboard, seed_db):
assert excinfo.value.status_code == 404


# ================================================================================
# Test duplicate_dashboard tabs (NEW in feature/dashboard_tabs)
# ================================================================================


class TestDuplicateDashboardTabs:
"""Tests for duplicate_dashboard() tabs copying with filter ID remapping"""

def test_duplicate_dashboard_copies_empty_tabs(self, orguser, sample_dashboard, seed_db):
"""Test that duplicating a dashboard with no tabs results in empty tabs"""
sample_dashboard.tabs = []
sample_dashboard.save()

request = mock_request(orguser)
response = duplicate_dashboard(request, dashboard_id=sample_dashboard.id)

assert response.tabs == []

def test_duplicate_dashboard_copies_tabs_without_filters(
self, orguser, sample_dashboard, seed_db
):
"""Test that tabs without filter components are copied as-is"""
sample_dashboard.tabs = [
{
"id": "tab-1",
"title": "Tab 1",
"layout_config": [{"i": "chart-1", "x": 0, "y": 0, "w": 4, "h": 3}],
"components": {"chart-1": {"type": "chart"}},
}
]
sample_dashboard.save()

request = mock_request(orguser)
response = duplicate_dashboard(request, dashboard_id=sample_dashboard.id)

assert len(response.tabs) == 1
assert response.tabs[0].title == "Tab 1"
assert response.tabs[0].components == {"chart-1": {"type": "chart"}}

def test_duplicate_dashboard_tabs_filter_ids_are_remapped(
self, orguser, sample_dashboard, seed_db
):
"""Test that filter IDs in tab layout_config and components are remapped to new IDs"""
original_filter = DashboardFilter.objects.create(
dashboard=sample_dashboard,
name="State Filter",
filter_type="value",
schema_name="public",
table_name="orders",
column_name="state",
settings={},
order=0,
)

sample_dashboard.tabs = [
{
"id": "tab-1",
"title": "Tab 1",
"layout_config": [{"i": f"filter-{original_filter.id}"}],
"components": {
f"filter-{original_filter.id}": {
"type": "filter",
"config": {"filterId": original_filter.id},
}
},
}
]
sample_dashboard.save()

request = mock_request(orguser)
response = duplicate_dashboard(request, dashboard_id=sample_dashboard.id)

new_tab = response.tabs[0]
new_filter_key = list(new_tab.components.keys())[0]
new_filter_id = int(new_filter_key.replace("filter-", ""))

# Old filter ID must NOT appear in the new tab
assert f"filter-{original_filter.id}" not in [
item["i"] for item in new_tab.layout_config
]
# New filter ID must appear in layout_config
assert f"filter-{new_filter_id}" in [item["i"] for item in new_tab.layout_config]
# New filter ID must appear in components config
assert new_tab.components[f"filter-{new_filter_id}"]["config"]["filterId"] == new_filter_id
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Cleanup
original_filter.delete()


# ================================================================================
# Test seed data
# ================================================================================
Expand Down
Loading
Loading