diff --git a/ddpui/api/dashboard_native_api.py b/ddpui/api/dashboard_native_api.py index 144f03bb3..7408a3c76 100644 --- a/ddpui/api/dashboard_native_api.py +++ b/ddpui/api/dashboard_native_api.py @@ -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 + 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( diff --git a/ddpui/management/commands/migrate_dashboards_to_tabs.py b/ddpui/management/commands/migrate_dashboards_to_tabs.py new file mode 100644 index 000000000..d2a701bf1 --- /dev/null +++ b/ddpui/management/commands/migrate_dashboards_to_tabs.py @@ -0,0 +1,95 @@ +import time +from django.core.management.base import BaseCommand +from ddpui.models.dashboard import Dashboard + + +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 ===" + ) + ) diff --git a/ddpui/migrations/0160_add_dashboard_tabs.py b/ddpui/migrations/0160_add_dashboard_tabs.py new file mode 100644 index 000000000..06ecca2e3 --- /dev/null +++ b/ddpui/migrations/0160_add_dashboard_tabs.py @@ -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}]", + ), + ), + ] diff --git a/ddpui/models/dashboard.py b/ddpui/models/dashboard.py index 5cdd8cfe6..c9ff0f4a1 100644 --- a/ddpui/models/dashboard.py +++ b/ddpui/models/dashboard.py @@ -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}]" + ) + # Filter layout configuration filter_layout = models.CharField( max_length=20, @@ -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, diff --git a/ddpui/schemas/dashboard_schema.py b/ddpui/schemas/dashboard_schema.py index da7c6a667..64dced0f1 100644 --- a/ddpui/schemas/dashboard_schema.py +++ b/ddpui/schemas/dashboard_schema.py @@ -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""" @@ -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 @@ -63,6 +77,7 @@ class DashboardResponse(Schema): filter_layout: str layout_config: list[dict] components: dict + tabs: List[DashboardTabSchema] = [] # Array of tabs is_published: bool published_at: Optional[datetime] = None is_locked: bool = False diff --git a/ddpui/services/dashboard_service.py b/ddpui/services/dashboard_service.py index f59399978..b25b7487e 100644 --- a/ddpui/services/dashboard_service.py +++ b/ddpui/services/dashboard_service.py @@ -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 @@ -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)}", + "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, @@ -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: diff --git a/ddpui/tests/api_tests/test_dashboard_native_api.py b/ddpui/tests/api_tests/test_dashboard_native_api.py index f4e6228d1..46ddd8cc0 100644 --- a/ddpui/tests/api_tests/test_dashboard_native_api.py +++ b/ddpui/tests/api_tests/test_dashboard_native_api.py @@ -34,6 +34,7 @@ create_dashboard, update_dashboard, delete_dashboard, + duplicate_dashboard, create_filter, update_filter, delete_filter, @@ -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 + + # Cleanup + original_filter.delete() + + # ================================================================================ # Test seed data # ================================================================================ diff --git a/ddpui/tests/services/test_dashboard_service.py b/ddpui/tests/services/test_dashboard_service.py index 97b395000..883cb1913 100644 --- a/ddpui/tests/services/test_dashboard_service.py +++ b/ddpui/tests/services/test_dashboard_service.py @@ -36,7 +36,7 @@ FilterNotFoundError, FilterValidationError, ) -from ddpui.schemas.dashboard_schema import DashboardUpdate +from ddpui.schemas.dashboard_schema import DashboardCreate, DashboardUpdate, DashboardTabSchema from ddpui.tests.api_tests.test_user_org_api import seed_db pytestmark = pytest.mark.django_db @@ -437,6 +437,94 @@ def test_filter_data_optional_fields(self): assert data.order == 0 # Default +# ================================================================================ +# Test create_dashboard default tab (NEW in feature/dashboard_tabs) +# ================================================================================ + + +class TestCreateDashboardDefaultTab: + """Tests for DashboardService.create_dashboard() default tab generation""" + + def test_create_dashboard_has_default_tab(self, orguser, seed_db): + """Test that a new dashboard is created with exactly one default tab""" + dashboard = DashboardService.create_dashboard( + DashboardCreate(title="Tab Test Dashboard"), + orguser, + ) + + assert len(dashboard.tabs) == 1 + + # Cleanup + dashboard.delete() + + def test_create_dashboard_default_tab_structure(self, orguser, seed_db): + """Test that the default tab has the correct structure""" + dashboard = DashboardService.create_dashboard( + DashboardCreate(title="Tab Structure Dashboard"), + orguser, + ) + + tab = dashboard.tabs[0] + assert tab["title"] == "Untitled Tab 1" + assert tab["id"].startswith("tab-") + assert tab["layout_config"] == [] + assert tab["components"] == {} + + # Cleanup + dashboard.delete() + + +# ================================================================================ +# Test update_dashboard tabs (NEW in feature/dashboard_tabs) +# ================================================================================ + + +class TestUpdateDashboardTabs: + """Tests for DashboardService.update_dashboard() tabs handling""" + + def test_update_dashboard_tabs_saves_correctly(self, orguser, sample_dashboard, seed_db): + """Test that providing tabs in update saves them as dicts""" + new_tabs = [ + DashboardTabSchema( + id="tab-111", + title="My Tab", + layout_config=[{"i": "chart-1", "x": 0, "y": 0, "w": 4, "h": 3}], + components={"chart-1": {"type": "chart"}}, + ) + ] + + updated = DashboardService.update_dashboard( + sample_dashboard.id, + orguser.org, + orguser, + DashboardUpdate(tabs=new_tabs), + ) + + assert len(updated.tabs) == 1 + assert updated.tabs[0]["id"] == "tab-111" + assert updated.tabs[0]["title"] == "My Tab" + assert updated.tabs[0]["layout_config"] == [{"i": "chart-1", "x": 0, "y": 0, "w": 4, "h": 3}] + assert updated.tabs[0]["components"] == {"chart-1": {"type": "chart"}} + + def test_update_dashboard_without_tabs_preserves_existing( + self, orguser, sample_dashboard, seed_db + ): + """Test that omitting tabs in update does not overwrite existing tabs""" + sample_dashboard.tabs = [{"id": "tab-existing", "title": "Existing Tab", "layout_config": [], "components": {}}] + sample_dashboard.save() + + updated = DashboardService.update_dashboard( + sample_dashboard.id, + orguser.org, + orguser, + DashboardUpdate(title="New Title Only"), + ) + + assert updated.title == "New Title Only" + assert len(updated.tabs) == 1 + assert updated.tabs[0]["id"] == "tab-existing" + + # ================================================================================ # Test resolve_dashboard_filters_for_chart # ================================================================================