Sprint 4 - Plugin Commands implementation adding 18 new user-facing commands across 8 plugins as part of V5.2.0 Plugin Enhancements. **projman:** - #241: /sprint-diagram - Mermaid visualization of sprint issues **pr-review:** - #242: Confidence threshold config (PR_REVIEW_CONFIDENCE_THRESHOLD) - #243: /pr-diff - Formatted diff with inline review comments **data-platform:** - #244: /data-quality - DataFrame quality checks (nulls, duplicates, outliers) - #245: /lineage-viz - dbt lineage as Mermaid diagrams - #246: /dbt-test - Formatted dbt test runner **viz-platform:** - #247: /chart-export - Export charts to PNG/SVG/PDF via kaleido - #248: /accessibility-check - Color blind validation (WCAG contrast) - #249: /breakpoints - Responsive layout configuration **contract-validator:** - #250: /dependency-graph - Plugin dependency visualization **doc-guardian:** - #251: /changelog-gen - Generate changelog from conventional commits - #252: /doc-coverage - Documentation coverage metrics - #253: /stale-docs - Flag outdated documentation **claude-config-maintainer:** - #254: /config-diff - Track CLAUDE.md changes over time - #255: /config-lint - 31 lint rules for CLAUDE.md best practices **cmdb-assistant:** - #256: /cmdb-topology - Infrastructure topology diagrams - #257: /change-audit - NetBox audit trail queries - #258: /ip-conflicts - Detect IP conflicts and overlaps Closes #241, #242, #243, #244, #245, #246, #247, #248, #249, #250, #251, #252, #253, #254, #255, #256, #257, #258 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
554 lines
18 KiB
Python
554 lines
18 KiB
Python
"""
|
|
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
|
|
}
|