feat(viz-platform): implement layout tools (#174)

- Add layout_create tool: create layouts with templates (dashboard, report, form, blank)
- Add layout_add_filter tool: add filter controls (dropdown, date_range, search, etc.)
- Add layout_set_grid tool: configure responsive grid system (1-24 cols, breakpoints)
- 4 templates and 9 filter types supported
- Maps to DMC Grid and AppShell component patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 11:50:10 -05:00
parent c91f21f3d1
commit 057c61fb16
2 changed files with 493 additions and 5 deletions

View File

@@ -0,0 +1,367 @@
"""
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__)
# 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()
}

View File

@@ -14,6 +14,7 @@ from mcp.types import Tool, TextContent
from .config import VizPlatformConfig
from .dmc_tools import DMCTools
from .chart_tools import ChartTools
from .layout_tools import LayoutTools
# Suppress noisy MCP validation warnings on stderr
logging.basicConfig(level=logging.INFO)
@@ -30,8 +31,8 @@ class VizPlatformMCPServer:
self.config = None
self.dmc_tools = DMCTools()
self.chart_tools = ChartTools()
self.layout_tools = LayoutTools()
# Tool handlers will be added in subsequent issues
# self.layout_tools = None
# self.theme_tools = None
# self.page_tools = None
@@ -197,9 +198,86 @@ class VizPlatformMCPServer:
))
# Layout tools (Issue #174)
# - layout_create
# - layout_add_filter
# - layout_set_grid
tools.append(Tool(
name="layout_create",
description=(
"Create a new dashboard layout container. "
"Templates available: dashboard, report, form, blank. "
"Returns layout reference for use with other layout tools."
),
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Unique name for the layout"
},
"template": {
"type": "string",
"enum": ["dashboard", "report", "form", "blank"],
"description": "Layout template to use (default: blank)"
}
},
"required": ["name"]
}
))
tools.append(Tool(
name="layout_add_filter",
description=(
"Add a filter control to a layout. "
"Filter types: dropdown, multi_select, date_range, date, search, "
"checkbox_group, radio_group, slider, range_slider."
),
inputSchema={
"type": "object",
"properties": {
"layout_ref": {
"type": "string",
"description": "Layout name to add filter to"
},
"filter_type": {
"type": "string",
"enum": ["dropdown", "multi_select", "date_range", "date",
"search", "checkbox_group", "radio_group", "slider", "range_slider"],
"description": "Type of filter control"
},
"options": {
"type": "object",
"description": (
"Filter options: label, data (for dropdown), placeholder, "
"position (section name), value, etc."
)
}
},
"required": ["layout_ref", "filter_type", "options"]
}
))
tools.append(Tool(
name="layout_set_grid",
description=(
"Configure the grid system for a layout. "
"Uses DMC Grid component patterns with 12 or 24 column system."
),
inputSchema={
"type": "object",
"properties": {
"layout_ref": {
"type": "string",
"description": "Layout name to configure"
},
"grid": {
"type": "object",
"description": (
"Grid config: cols (1-24), spacing (xs|sm|md|lg|xl), "
"breakpoints ({xs: cols, sm: cols, ...}), gutter"
)
}
},
"required": ["layout_ref", "grid"]
}
))
# Theme tools (Issue #175)
# - theme_create
@@ -285,7 +363,50 @@ class VizPlatformMCPServer:
text=json.dumps(result, indent=2)
)]
# Layout tools (Issue #174)
# Layout tools
elif name == "layout_create":
layout_name = arguments.get('name')
template = arguments.get('template')
if not layout_name:
return [TextContent(
type="text",
text=json.dumps({"error": "name is required"}, indent=2)
)]
result = await self.layout_tools.layout_create(layout_name, template)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
elif name == "layout_add_filter":
layout_ref = arguments.get('layout_ref')
filter_type = arguments.get('filter_type')
options = arguments.get('options', {})
if not layout_ref or not filter_type:
return [TextContent(
type="text",
text=json.dumps({"error": "layout_ref and filter_type are required"}, indent=2)
)]
result = await self.layout_tools.layout_add_filter(layout_ref, filter_type, options)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
elif name == "layout_set_grid":
layout_ref = arguments.get('layout_ref')
grid = arguments.get('grid', {})
if not layout_ref:
return [TextContent(
type="text",
text=json.dumps({"error": "layout_ref is required"}, indent=2)
)]
result = await self.layout_tools.layout_set_grid(layout_ref, grid)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
# Theme tools (Issue #175)
# Page tools (Issue #176)