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:
367
mcp-servers/viz-platform/mcp_server/layout_tools.py
Normal file
367
mcp-servers/viz-platform/mcp_server/layout_tools.py
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user