""" Layout composition tools for dashboard building. Provides tools for creating structured layouts with grids, filters, and sections. """ import logging from typing import Dict, List, Optional, Any from uuid import uuid4 logger = logging.getLogger(__name__) # Standard responsive breakpoints (Mantine/Bootstrap-aligned) DEFAULT_BREAKPOINTS = { "xs": { "min_width": "0px", "max_width": "575px", "cols": 1, "spacing": "xs", "description": "Extra small devices (phones, portrait)" }, "sm": { "min_width": "576px", "max_width": "767px", "cols": 2, "spacing": "sm", "description": "Small devices (phones, landscape)" }, "md": { "min_width": "768px", "max_width": "991px", "cols": 6, "spacing": "md", "description": "Medium devices (tablets)" }, "lg": { "min_width": "992px", "max_width": "1199px", "cols": 12, "spacing": "md", "description": "Large devices (desktops)" }, "xl": { "min_width": "1200px", "max_width": None, "cols": 12, "spacing": "lg", "description": "Extra large devices (large desktops)" } } # Layout templates TEMPLATES = { "dashboard": { "sections": ["header", "filters", "main", "footer"], "default_grid": {"cols": 12, "spacing": "md"}, "description": "Standard dashboard with header, filters, main content, and footer" }, "report": { "sections": ["title", "summary", "content", "appendix"], "default_grid": {"cols": 1, "spacing": "lg"}, "description": "Report layout with title, summary, and content sections" }, "form": { "sections": ["header", "fields", "actions"], "default_grid": {"cols": 2, "spacing": "md"}, "description": "Form layout with header, fields, and action buttons" }, "blank": { "sections": ["main"], "default_grid": {"cols": 12, "spacing": "md"}, "description": "Blank canvas for custom layouts" } } # Filter type definitions FILTER_TYPES = { "dropdown": { "component": "Select", "props": ["label", "data", "placeholder", "clearable", "searchable", "value"] }, "multi_select": { "component": "MultiSelect", "props": ["label", "data", "placeholder", "clearable", "searchable", "value"] }, "date_range": { "component": "DateRangePicker", "props": ["label", "placeholder", "value", "minDate", "maxDate"] }, "date": { "component": "DatePicker", "props": ["label", "placeholder", "value", "minDate", "maxDate"] }, "search": { "component": "TextInput", "props": ["label", "placeholder", "value", "icon"] }, "checkbox_group": { "component": "CheckboxGroup", "props": ["label", "children", "value"] }, "radio_group": { "component": "RadioGroup", "props": ["label", "children", "value"] }, "slider": { "component": "Slider", "props": ["label", "min", "max", "step", "value", "marks"] }, "range_slider": { "component": "RangeSlider", "props": ["label", "min", "max", "step", "value", "marks"] } } class LayoutTools: """ Dashboard layout composition tools. Creates layouts that map to DMC Grid and AppShell components. """ def __init__(self): """Initialize layout tools.""" self._layouts: Dict[str, Dict[str, Any]] = {} async def layout_create( self, name: str, template: Optional[str] = None ) -> Dict[str, Any]: """ Create a new layout container. Args: name: Unique name for the layout template: Optional template (dashboard, report, form, blank) Returns: Dict with: - layout_ref: Reference to use in other tools - template: Template used - sections: Available sections - grid: Default grid configuration """ # Validate template template = template or "blank" if template not in TEMPLATES: return { "error": f"Invalid template '{template}'. Must be one of: {list(TEMPLATES.keys())}", "layout_ref": None } # Check for name collision if name in self._layouts: return { "error": f"Layout '{name}' already exists. Use a different name or modify existing.", "layout_ref": name } template_config = TEMPLATES[template] # Create layout structure layout = { "id": str(uuid4()), "name": name, "template": template, "sections": {section: {"items": []} for section in template_config["sections"]}, "grid": template_config["default_grid"].copy(), "filters": [], "metadata": { "description": template_config["description"] } } self._layouts[name] = layout return { "layout_ref": name, "template": template, "sections": template_config["sections"], "grid": layout["grid"], "description": template_config["description"] } async def layout_add_filter( self, layout_ref: str, filter_type: str, options: Dict[str, Any] ) -> Dict[str, Any]: """ Add a filter control to a layout. Args: layout_ref: Layout name to add filter to filter_type: Type of filter (dropdown, date_range, search, checkbox_group, etc.) options: Filter options (label, data for dropdown, placeholder, position) Returns: Dict with: - filter_id: Unique ID for the filter - component: DMC component that will be used - props: Props that were set - position: Where filter was placed """ # Validate layout exists if layout_ref not in self._layouts: return { "error": f"Layout '{layout_ref}' not found. Create it first with layout_create.", "filter_id": None } # Validate filter type if filter_type not in FILTER_TYPES: return { "error": f"Invalid filter_type '{filter_type}'. Must be one of: {list(FILTER_TYPES.keys())}", "filter_id": None } filter_config = FILTER_TYPES[filter_type] layout = self._layouts[layout_ref] # Generate filter ID filter_id = f"filter_{filter_type}_{len(layout['filters'])}" # Extract relevant props props = {"id": filter_id} for prop in filter_config["props"]: if prop in options: props[prop] = options[prop] # Determine position position = options.get("position", "filters") if position not in layout["sections"]: # Default to first available section position = list(layout["sections"].keys())[0] # Create filter definition filter_def = { "id": filter_id, "type": filter_type, "component": filter_config["component"], "props": props, "position": position } layout["filters"].append(filter_def) layout["sections"][position]["items"].append({ "type": "filter", "ref": filter_id }) return { "filter_id": filter_id, "component": filter_config["component"], "props": props, "position": position, "layout_ref": layout_ref } async def layout_set_grid( self, layout_ref: str, grid: Dict[str, Any] ) -> Dict[str, Any]: """ Configure the grid system for a layout. Args: layout_ref: Layout name to configure grid: Grid configuration: - cols: Number of columns (default 12) - spacing: Gap between items (xs, sm, md, lg, xl) - breakpoints: Responsive breakpoints {xs: cols, sm: cols, ...} - gutter: Gutter size Returns: Dict with: - grid: Updated grid configuration - layout_ref: Layout reference """ # Validate layout exists if layout_ref not in self._layouts: return { "error": f"Layout '{layout_ref}' not found. Create it first with layout_create.", "grid": None } layout = self._layouts[layout_ref] # Validate spacing if provided valid_spacing = ["xs", "sm", "md", "lg", "xl"] if "spacing" in grid and grid["spacing"] not in valid_spacing: return { "error": f"Invalid spacing '{grid['spacing']}'. Must be one of: {valid_spacing}", "grid": layout["grid"] } # Validate cols if "cols" in grid: cols = grid["cols"] if not isinstance(cols, int) or cols < 1 or cols > 24: return { "error": f"Invalid cols '{cols}'. Must be integer between 1 and 24.", "grid": layout["grid"] } # Update grid configuration layout["grid"].update(grid) # Process breakpoints if provided if "breakpoints" in grid: bp = grid["breakpoints"] layout["grid"]["breakpoints"] = bp return { "grid": layout["grid"], "layout_ref": layout_ref } async def layout_get(self, layout_ref: str) -> Dict[str, Any]: """ Get a layout's full configuration. Args: layout_ref: Layout name to retrieve Returns: Full layout configuration or error """ if layout_ref not in self._layouts: return { "error": f"Layout '{layout_ref}' not found.", "layout": None } layout = self._layouts[layout_ref] return { "layout": layout, "filter_count": len(layout["filters"]), "sections": list(layout["sections"].keys()) } async def layout_add_section( self, layout_ref: str, section_name: str, position: Optional[int] = None ) -> Dict[str, Any]: """ Add a custom section to a layout. Args: layout_ref: Layout name section_name: Name for the new section position: Optional position index (appends if not specified) Returns: Dict with sections list and the new section name """ if layout_ref not in self._layouts: return { "error": f"Layout '{layout_ref}' not found.", "sections": [] } layout = self._layouts[layout_ref] if section_name in layout["sections"]: return { "error": f"Section '{section_name}' already exists.", "sections": list(layout["sections"].keys()) } # Add new section layout["sections"][section_name] = {"items": []} return { "section_name": section_name, "sections": list(layout["sections"].keys()), "layout_ref": layout_ref } def get_available_templates(self) -> Dict[str, Any]: """Get list of available layout templates.""" return { name: { "sections": config["sections"], "description": config["description"] } for name, config in TEMPLATES.items() } def get_available_filter_types(self) -> Dict[str, Any]: """Get list of available filter types.""" return { name: { "component": config["component"], "props": config["props"] } for name, config in FILTER_TYPES.items() } async def layout_set_breakpoints( self, layout_ref: str, breakpoints: Dict[str, Dict[str, Any]], mobile_first: bool = True ) -> Dict[str, Any]: """ Configure responsive breakpoints for a layout. Args: layout_ref: Layout name to configure breakpoints: Breakpoint configuration dict: { "xs": {"cols": 1, "spacing": "xs"}, "sm": {"cols": 2, "spacing": "sm"}, "md": {"cols": 6, "spacing": "md"}, "lg": {"cols": 12, "spacing": "md"}, "xl": {"cols": 12, "spacing": "lg"} } mobile_first: If True, use min-width media queries (default) Returns: Dict with: - breakpoints: Complete breakpoint configuration - css_media_queries: Generated CSS media queries - mobile_first: Whether mobile-first approach is used """ # Validate layout exists if layout_ref not in self._layouts: return { "error": f"Layout '{layout_ref}' not found. Create it first with layout_create.", "breakpoints": None } layout = self._layouts[layout_ref] # Validate breakpoint names valid_breakpoints = ["xs", "sm", "md", "lg", "xl"] for bp_name in breakpoints.keys(): if bp_name not in valid_breakpoints: return { "error": f"Invalid breakpoint '{bp_name}'. Must be one of: {valid_breakpoints}", "breakpoints": layout.get("breakpoints") } # Merge with defaults merged_breakpoints = {} for bp_name in valid_breakpoints: default = DEFAULT_BREAKPOINTS[bp_name].copy() if bp_name in breakpoints: default.update(breakpoints[bp_name]) merged_breakpoints[bp_name] = default # Validate spacing values valid_spacing = ["xs", "sm", "md", "lg", "xl"] for bp_name, bp_config in merged_breakpoints.items(): if "spacing" in bp_config and bp_config["spacing"] not in valid_spacing: return { "error": f"Invalid spacing '{bp_config['spacing']}' for breakpoint '{bp_name}'. Must be one of: {valid_spacing}", "breakpoints": layout.get("breakpoints") } # Validate column counts for bp_name, bp_config in merged_breakpoints.items(): if "cols" in bp_config: cols = bp_config["cols"] if not isinstance(cols, int) or cols < 1 or cols > 24: return { "error": f"Invalid cols '{cols}' for breakpoint '{bp_name}'. Must be integer between 1 and 24.", "breakpoints": layout.get("breakpoints") } # Generate CSS media queries css_queries = self._generate_media_queries(merged_breakpoints, mobile_first) # Store in layout layout["breakpoints"] = merged_breakpoints layout["mobile_first"] = mobile_first layout["responsive_css"] = css_queries return { "layout_ref": layout_ref, "breakpoints": merged_breakpoints, "mobile_first": mobile_first, "css_media_queries": css_queries } def _generate_media_queries( self, breakpoints: Dict[str, Dict[str, Any]], mobile_first: bool ) -> List[str]: """Generate CSS media queries for breakpoints.""" queries = [] bp_order = ["xs", "sm", "md", "lg", "xl"] if mobile_first: # Use min-width queries (mobile-first) for bp_name in bp_order[1:]: # Skip xs (base styles) bp = breakpoints[bp_name] min_width = bp.get("min_width", DEFAULT_BREAKPOINTS[bp_name]["min_width"]) if min_width and min_width != "0px": queries.append(f"@media (min-width: {min_width}) {{ /* {bp_name} styles */ }}") else: # Use max-width queries (desktop-first) for bp_name in reversed(bp_order[:-1]): # Skip xl (base styles) bp = breakpoints[bp_name] max_width = bp.get("max_width", DEFAULT_BREAKPOINTS[bp_name]["max_width"]) if max_width: queries.append(f"@media (max-width: {max_width}) {{ /* {bp_name} styles */ }}") return queries async def layout_get_breakpoints(self, layout_ref: str) -> Dict[str, Any]: """ Get the breakpoint configuration for a layout. Args: layout_ref: Layout name Returns: Dict with breakpoint configuration """ if layout_ref not in self._layouts: return { "error": f"Layout '{layout_ref}' not found.", "breakpoints": None } layout = self._layouts[layout_ref] return { "layout_ref": layout_ref, "breakpoints": layout.get("breakpoints", DEFAULT_BREAKPOINTS.copy()), "mobile_first": layout.get("mobile_first", True), "css_media_queries": layout.get("responsive_css", []) } def get_default_breakpoints(self) -> Dict[str, Any]: """Get the default breakpoint configuration.""" return { "breakpoints": DEFAULT_BREAKPOINTS.copy(), "description": "Standard responsive breakpoints aligned with Mantine/Bootstrap", "mobile_first": True }