Source code for dashboard_lego.core.page.core

"""
This module defines the DashboardPage class, which orchestrates blocks on a page.

"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional

import dash_bootstrap_components as dbc
import plotly.graph_objects as go
from dash import html
from dash.development.base_component import Component

from dashboard_lego.core.exceptions import ConfigurationError
from dashboard_lego.core.page.callbacks import CallbacksMixin
from dashboard_lego.core.page.layout_builder import LayoutBuilderMixin
from dashboard_lego.core.page.navigation import NavigationMixin
from dashboard_lego.core.page.sidebar_builder import SidebarBuilderMixin
from dashboard_lego.core.page.theme_manager import ThemeManagerMixin
from dashboard_lego.core.state import StateManager
from dashboard_lego.core.theme import ThemeConfig
from dashboard_lego.utils.logger import get_logger

# Lazy import for SidebarConfig to avoid circular dependency
if TYPE_CHECKING:
    from dashboard_lego.core.sidebar import SidebarConfig










[docs] class DashboardPage( LayoutBuilderMixin, NavigationMixin, SidebarBuilderMixin, CallbacksMixin, ThemeManagerMixin, ): """ Orchestrates the assembly of a dashboard page from a list of blocks. :hierarchy: [Feature | Layout System | Page Modification] :relates-to: - motivated_by: "Architectural Conclusion: Provide a flexible grid-based layout system" - implements: "class: 'DashboardPage'" - uses: ["interface: 'BaseBlock'", "class: 'StateManager'"] :rationale: "The page now accepts a nested list structure for layout definition and builds a Bootstrap grid, offering a balance of power and simplicity." :contract: - pre: "`blocks` must be a list of lists, where each inner item is a BaseBlock or a (BaseBlock, dict) tuple." - post: "A complete Dash layout with a grid structure can be retrieved." """
[docs] def __init__( self, title: str, blocks: Optional[List[List[Any]]] = None, theme: str = dbc.themes.BOOTSTRAP, navigation: Optional[NavigationConfig] = None, theme_config: Optional[ThemeConfig] = None, sidebar: Optional["SidebarConfig"] = None, ): """ Initializes the DashboardPage, creates a StateManager, and registers all blocks. Args: title: The main title of the dashboard page. blocks: A list of lists representing rows. Each item in a row is either a BaseBlock instance or a tuple of ``(BaseBlock, dict_of_col_props)``. Example:: [[block1], [(block2, {'width': 8}), (block3, {'width': 4})]] If navigation is provided, this parameter is optional. theme: An optional URL to a dash-bootstrap-components theme (e.g., ``dbc.themes.CYBORG``). navigation: Optional NavigationConfig for multi-section dashboard with lazy-loaded content. theme_config: Optional ThemeConfig for global styling customization. sidebar: Optional SidebarConfig for collapsible sidebar with fixed-ID blocks. Sidebar blocks use non-pattern-matched IDs, enabling cross-section State() subscriptions in pattern-matching callbacks. """ # Lazy import to avoid circular dependency from dashboard_lego.blocks.base import BaseBlock self.logger = get_logger(__name__, DashboardPage) self.logger.info(f"Initializing dashboard page: '{title}'") self.title = title self.theme = theme self.navigation = navigation self.sidebar = sidebar # Auto-derive theme_config from dbc theme if not explicitly provided if theme_config is None: self.logger.debug(f"Auto-deriving ThemeConfig from theme: {theme}") self.theme_config = ThemeConfig.from_dbc_theme(theme) else: self.theme_config = theme_config self.logger.info(f"Using theme: {self.theme_config.name}") if self.sidebar: self.logger.info( f"Sidebar enabled | blocks={len(self.sidebar.blocks)} " f"position={self.sidebar.position} collapsible={self.sidebar.collapsible}" ) self.layout_structure = blocks or [] self.state_manager = StateManager() # Validate that either blocks or navigation is provided if not blocks and not navigation: raise ConfigurationError( "Either 'blocks' or 'navigation' must be provided to DashboardPage" ) # Flatten the structure to get all block instances for registration # (Only for non-navigation mode; navigation uses lazy loading) self.blocks: List[BaseBlock] = [] if not self.navigation: # Standard mode: register all blocks immediately try: for row_idx, row in enumerate(self.layout_structure): # Handle both old format (list of blocks) and new format (tuple of (list, dict)) if isinstance(row, tuple) and len(row) == 2: # New format: (list_of_blocks, row_options) blocks_list = row[0] else: # Old format: list of blocks blocks_list = row self.logger.debug( f"Processing row {row_idx} with {len(blocks_list)} blocks" ) for item in blocks_list: block = item[0] if isinstance(item, tuple) else item if not isinstance(block, BaseBlock): error_msg = ( f"All layout items must be of type BaseBlock. " f"Got {type(block)} in row {row_idx}" ) self.logger.error(error_msg) raise ConfigurationError(error_msg) self.blocks.append(block) self.logger.info( f"Page structure validated: {len(self.layout_structure)} rows, " f"{len(self.blocks)} blocks total" ) except Exception as e: self.logger.error(f"Failed to process page structure: {e}") raise # Register all blocks with the state manager and inject theme self.logger.debug("Registering blocks with state manager") self.logger.debug( f"Registering {len(self.blocks)} blocks with state manager" ) for block in self.blocks: self.logger.debug(f"Registering block: {block.block_id}") # Inject theme configuration block._set_theme_config(self.theme_config) # Register state interactions block._register_state_interactions(self.state_manager) else: # Navigation mode: blocks will be created and registered lazily self.logger.info( f"Navigation mode enabled with {len(self.navigation.sections)} sections" ) # Cache for lazily loaded sections: {section_index: List[BaseBlock]} self._section_blocks_cache: Dict[int, List[BaseBlock]] = {}
# --- Layout v2: helper constants --- _CELL_ALLOWED_KEYS: set = { "width", "xs", "sm", "md", "lg", "xl", "offset", "align", "className", "style", "children", } _ROW_ALLOWED_KEYS: set = {"align", "justify", "g", "className", "style"} def _build_sidebar_layout(self) -> Component: """ Build layout with dbc.Offcanvas collapsible sidebar. UNIFIED SIDEBAR: Contains navigation links (if navigation enabled) + control blocks. :hierarchy: [Core | Layout | Sidebar | BuildLayout] :relates-to: - motivated_by: "Pattern-matching callbacks + unified sidebar UX" - implements: "method: '_build_sidebar_layout'" - uses: ["class: 'SidebarConfig'", "component: 'dbc.Offcanvas'"] :contract: - pre: "self.sidebar is not None and validated" - post: "ONE Offcanvas with navigation (if enabled) + controls" - invariant: "Sidebar blocks always use fixed string IDs" - spec_compliance: "Sidebar + Navigation: ONE dbc.Offcanvas component" :complexity: 6 :decision_cache: "Unified sidebar: Navigation links at top, controls below" :returns: - Component: html.Div containing ONE offcanvas, toggle button, and main content """ self.logger.info( f"[Core|Sidebar|BuildLayout] Building UNIFIED sidebar layout | " f"position={self.sidebar.position} width={self.sidebar.width} " f"has_navigation={self.navigation is not None}" ) # <semantic_block: sidebar_content_assembly> sidebar_components = [] # Add navigation links at TOP if navigation enabled if self.navigation: self.logger.debug( "[Core|Sidebar|BuildLayout] Adding navigation links to sidebar" ) # Title sidebar_components.append( html.Div( [ html.I(className="fas fa-tachometer-alt me-2"), html.H4( self.title, className="mb-0 d-inline", style={"color": self.theme_config.colors.nav_text}, ), ], className="mb-3", ) ) # Navigation section sidebar_components.append( html.Div( [ html.P( "Navigate between sections", className="small mb-2", style={ "color": self.theme_config.colors.nav_text, "opacity": "0.7", }, ), dbc.Nav( self._build_navigation_links(), vertical=True, pills=True, id="nav-list", className=self.navigation.nav_className or "nav-pills-custom", style=self.navigation.nav_style or {}, ), ], className="mb-4", ) ) # Separator sidebar_components.append( html.Hr( style={ "borderColor": self.theme_config.colors.nav_text, "opacity": "0.3", "margin": "1rem 0", } ) ) # Add control blocks BELOW navigation control_blocks = self._render_sidebar_blocks() sidebar_components.extend(control_blocks) self.logger.debug( f"[Core|Sidebar|BuildLayout] Sidebar assembled | " f"components={len(sidebar_components)} " f"(nav={self.navigation is not None}, controls={len(control_blocks)})" ) # </semantic_block: sidebar_content_assembly> # <semantic_block: offcanvas_configuration> # Apply theme styles to Offcanvas # DBC Offcanvas has header + body, style both for theme consistency offcanvas_style = { "width": self.sidebar.width, "--bs-offcanvas-bg": self.theme_config.colors.nav_background, "--bs-offcanvas-color": self.theme_config.colors.nav_text, # NOTE(REVIEWER): --bs-btn-close-color is set globally in theme_manager.py # Bootstrap automatically inverts it for dark themes via data-bs-theme } # Add custom CSS class for additional control styling offcanvas_class = "themed-offcanvas" offcanvas = dbc.Offcanvas( id="sidebar-offcanvas", children=sidebar_components, title=self.sidebar.title or "Dashboard Controls", placement=self.sidebar.position, is_open=not self.sidebar.default_collapsed, backdrop=self.sidebar.backdrop, close_button=False, # NOTE(REVIEWER): User requirement - remove close button, burger becomes X style=offcanvas_style, className=offcanvas_class, ) self.logger.debug( f"[Core|Sidebar|BuildLayout] Offcanvas configured with theme | " f"bg={self.theme_config.colors.nav_background} | " f"text={self.theme_config.colors.nav_text}" ) # </semantic_block: offcanvas_configuration> # <semantic_block: toggle_button> toggle_btn = None if self.sidebar.collapsible: position_style = {"top": "10px", "zIndex": 1060} if self.sidebar.position == "start": position_style["left"] = "10px" else: position_style["right"] = "10px" # Button icon will be updated by callback based on sidebar state # Initial state: burger (☰) when closed, will become X (✕) when open initial_icon = "✕" if not self.sidebar.default_collapsed else "☰" toggle_btn = dbc.Button( initial_icon, id="sidebar-toggle-btn", size="sm", color="secondary", className="position-fixed", style={ **position_style, "width": "2.5rem", # Square button "height": "2.5rem", "display": "flex", "alignItems": "center", "justifyContent": "center", "padding": "0", }, ) self.logger.debug("[Core|Sidebar|BuildLayout] Toggle button created") # </semantic_block: toggle_button> # <semantic_block: main_content> # Build main content WITHOUT duplicate sidebar if self.navigation: self.logger.debug( "[Core|Sidebar|BuildLayout] Building navigation content (content area only)" ) # Build ONLY content area (no sidebar!) main_content = self._build_navigation_content_only() else: self.logger.debug( "[Core|Sidebar|BuildLayout] Building standard grid content" ) rows: List[Component] = [] for row_idx, row_spec in enumerate(self.layout_structure): normalized_cells, row_options = self._validate_row(row_spec) rows.append(self._render_row(normalized_cells, row_options)) main_content = [html.H1(self.title, className="my-4"), *rows] main_container = dbc.Container(main_content, fluid=True, className="p-3") # </semantic_block: main_content> self.logger.info( f"[Core|Sidebar|BuildLayout] UNIFIED sidebar layout complete | " f"toggle={toggle_btn is not None} | " f"nav_in_sidebar={self.navigation is not None}" ) return html.Div([toggle_btn, offcanvas, main_container])
[docs] def build_layout(self) -> Component: """ Assembles the layouts from all blocks into a grid-based page layout. Supports three layout modes: 1. Sidebar + Navigation: dbc.Offcanvas + multi-section navigation 2. Sidebar + Standard: dbc.Offcanvas + grid layout 3. Standard/Navigation: existing behavior (no sidebar) CRITICAL: For navigation mode, preload all sections BEFORE building layout to prevent duplicate block creation when combined with sidebar. :hierarchy: [Core | Page | BuildLayout] :relates-to: - motivated_by: "Dash callback lifecycle requires all blocks before app.run()" - implements: "method: 'build_layout' with navigation preload" - uses: ["method: '_preload_all_section_blocks'"] :contract: - pre: "Page configured with blocks or navigation" - post: "Layout built with all blocks created exactly once" - invariant: "Navigation sections preloaded before HTML rendering" - spec_compliance: "Dash callback registration lifecycle" :complexity: 5 :decision_cache: "Preload navigation before layout to prevent duplicate block creation" Returns: A Dash component representing the entire page. """ self.logger.info("Building page layout") # <semantic_block: navigation_preload> # CRITICAL: Preload navigation sections before layout build # Prevents duplicate block creation in sidebar+navigation mode # Ensures all blocks exist before register_callbacks() is called if self.navigation and not hasattr(self, "_sections_preloaded"): self.logger.info( f"Preloading {len(self.navigation.sections)} navigation sections " f"before layout build (prevents duplicate block creation)" ) self._preload_all_section_blocks() self._sections_preloaded = True self.logger.debug("Navigation sections preloaded successfully") # </semantic_block: navigation_preload> # Sidebar mode: use Offcanvas + main content if self.sidebar: self.logger.info( f"Building sidebar layout | position={self.sidebar.position}" ) return self._build_sidebar_layout() # Navigation mode: use navigation layout if self.navigation: self.logger.info("Building navigation-based layout") return self._build_navigation_layout() # Standard mode: use grid layout self.logger.debug( f"Building layout: {len(self.layout_structure)} rows, {len(self.blocks)} blocks" ) rows: List[Component] = [] try: for row_idx, row_spec in enumerate(self.layout_structure): # Validate and normalize the row and its cells normalized_cells, row_options = self._validate_row(row_spec) self.logger.debug( f"Rendering row {row_idx} with {len(normalized_cells)} cells and options {row_options}" ) rows.append(self._render_row(normalized_cells, row_options)) self.logger.info(f"Layout built successfully: {len(rows)} rows rendered") return dbc.Container( [html.H1(self.title, className="my-4"), *rows], fluid=True ) except Exception as e: self.logger.error(f"Error building layout: {e}", exc_info=True) raise
[docs] def export_to_figure( self, params: Optional[Dict[str, Any]] = None, title: Optional[str] = None, ) -> go.Figure: """ Export entire dashboard layout to single Plotly figure. Combines all chart blocks into a single figure using subplots. Non-chart blocks (metrics, text, controls) are skipped. Args: params: Optional parameters for filtering data in all blocks title: Optional title for the combined figure Returns: Single Plotly Figure with all charts in grid layout Example: >>> page = DashboardPage(title="Sales Dashboard", blocks=layout) >>> fig = page.export_to_figure(title="Q4 Sales Report") >>> fig.write_html("dashboard_export.html") Note: Requires dashboard to be built without navigation (single layout). For navigation dashboards, export sections individually. """ if self.navigation: raise ValueError( "Cannot export navigation dashboard. " "Export individual sections using section factories." ) from dashboard_lego.utils.layout_export import export_layout_to_figure return export_layout_to_figure( self.layout_structure, params=params, title=title or self.title, )