diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 5f7ec6c64df..6d726abed78 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -123,6 +123,10 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link: - chart_type="pie": Pie chart for proportional data (set donut=True for donut) - chart_type="pivot_table": Interactive pivot table for cross-tabulation - chart_type="mixed_timeseries": Dual-series chart combining two chart types +- chart_type="handlebars": Custom HTML template chart (KPI cards, leaderboards, reports) + Requires handlebars_template with Handlebars HTML template string. + Supports query_mode="aggregate" (with metrics/groupby) or "raw" (with columns). + Data available as {{{{data}}}} array; helpers: dateFormat, formatNumber, stringify. Time grain for temporal x-axis (time_grain parameter): - PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index 41190554b3d..e1292037d3f 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -31,6 +31,7 @@ from superset.mcp_service.chart.schemas import ( ChartSemantics, ColumnRef, FilterConfig, + HandlebarsChartConfig, MixedTimeseriesChartConfig, PieChartConfig, PivotTableChartConfig, @@ -309,7 +310,8 @@ def map_config_to_form_data( | XYChartConfig | PieChartConfig | PivotTableChartConfig - | MixedTimeseriesChartConfig, + | MixedTimeseriesChartConfig + | HandlebarsChartConfig, dataset_id: int | str | None = None, ) -> Dict[str, Any]: """Map chart config to Superset form_data.""" @@ -323,6 +325,8 @@ def map_config_to_form_data( return map_pivot_table_config(config) elif isinstance(config, MixedTimeseriesChartConfig): return map_mixed_timeseries_config(config, dataset_id=dataset_id) + elif isinstance(config, HandlebarsChartConfig): + return map_handlebars_config(config) else: raise ValueError(f"Unsupported config type: {type(config)}") @@ -638,6 +642,44 @@ def map_pie_config(config: PieChartConfig) -> Dict[str, Any]: return form_data +def map_handlebars_config(config: HandlebarsChartConfig) -> Dict[str, Any]: + """Map handlebars chart config to Superset form_data.""" + form_data: Dict[str, Any] = { + "viz_type": "handlebars", + "handlebars_template": config.handlebars_template, + "row_limit": config.row_limit, + "order_desc": config.order_desc, + } + + if config.style_template: + form_data["styleTemplate"] = config.style_template + + if config.query_mode == "raw": + form_data["query_mode"] = "raw" + if config.columns: + form_data["all_columns"] = [col.name for col in config.columns] + else: + form_data["query_mode"] = "aggregate" + if config.groupby: + form_data["groupby"] = [col.name for col in config.groupby] + if config.metrics: + form_data["metrics"] = [create_metric_object(col) for col in config.metrics] + if config.filters: + form_data["adhoc_filters"] = [ + { + "clause": "WHERE", + "expressionType": "SIMPLE", + "subject": filter_config.column, + "operator": map_filter_operator(filter_config.op), + "comparator": filter_config.value, + } + for filter_config in config.filters + if filter_config is not None + ] + + return form_data + + def map_pivot_table_config(config: PivotTableChartConfig) -> Dict[str, Any]: """Map pivot table config to Superset form_data.""" if not config.rows: @@ -908,12 +950,28 @@ def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str: return f"{primary} + {secondary}" +def _handlebars_chart_what(config: HandlebarsChartConfig) -> str: + """Build the 'what' portion for a handlebars chart name. + + Uses parentheses instead of en-dash to avoid collision with + ``generate_chart_name``'s ``\u2013`` context separator. + """ + if config.query_mode == "raw" and config.columns: + cols = ", ".join(col.name for col in config.columns[:3]) + return f"Handlebars ({cols})" + elif config.metrics: + metrics = ", ".join(col.name for col in config.metrics[:3]) + return f"Handlebars ({metrics})" + return "Handlebars Chart" + + def generate_chart_name( config: TableChartConfig | XYChartConfig | PieChartConfig | PivotTableChartConfig - | MixedTimeseriesChartConfig, + | MixedTimeseriesChartConfig + | HandlebarsChartConfig, dataset_name: str | None = None, ) -> str: """Generate a descriptive chart name following a standard format. @@ -944,6 +1002,9 @@ def generate_chart_name( elif isinstance(config, MixedTimeseriesChartConfig): what = _mixed_timeseries_what(config) context = _summarize_filters(config.filters) + elif isinstance(config, HandlebarsChartConfig): + what = _handlebars_chart_what(config) + context = _summarize_filters(getattr(config, "filters", None)) else: return "Chart" @@ -953,6 +1014,31 @@ def generate_chart_name( return _truncate(name) +def _resolve_viz_type(config: Any) -> str: + """Resolve the Superset viz_type from a chart config object.""" + chart_type = getattr(config, "chart_type", "unknown") + if chart_type == "xy": + kind = getattr(config, "kind", "line") + viz_type_map = { + "line": "echarts_timeseries_line", + "bar": "echarts_timeseries_bar", + "area": "echarts_area", + "scatter": "echarts_timeseries_scatter", + } + return viz_type_map.get(kind, "echarts_timeseries_line") + elif chart_type == "table": + return getattr(config, "viz_type", "table") + elif chart_type == "pie": + return "pie" + elif chart_type == "pivot_table": + return "pivot_table_v2" + elif chart_type == "mixed_timeseries": + return "mixed_timeseries" + elif chart_type == "handlebars": + return "handlebars" + return "unknown" + + def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities: """Analyze chart capabilities based on type and configuration.""" if chart: @@ -1003,29 +1089,6 @@ def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilit ) -def _resolve_viz_type(config: Any) -> str: - """Resolve viz_type from a chart config object.""" - chart_type = getattr(config, "chart_type", "unknown") - if chart_type == "xy": - kind = getattr(config, "kind", "line") - viz_type_map = { - "line": "echarts_timeseries_line", - "bar": "echarts_timeseries_bar", - "area": "echarts_area", - "scatter": "echarts_timeseries_scatter", - } - return viz_type_map.get(kind, "echarts_timeseries_line") - elif chart_type == "table": - return getattr(config, "viz_type", "table") - elif chart_type == "pie": - return "pie" - elif chart_type == "pivot_table": - return "pivot_table_v2" - elif chart_type == "mixed_timeseries": - return "mixed_timeseries" - return "unknown" - - def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics: """Generate semantic understanding of the chart.""" if chart: @@ -1052,6 +1115,10 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics: "Combines two different chart types on the same time axis " "for comparing related metrics with different scales" ), + "handlebars": ( + "Renders data using a custom Handlebars HTML template for " + "fully flexible layouts like KPI cards, leaderboards, and reports" + ), } primary_insight = insights_map.get( diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index b84bf9bc38b..f78c15f510d 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -659,6 +659,108 @@ class MixedTimeseriesChartConfig(BaseModel): return _normalize_group_by_input(v) +class HandlebarsChartConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + chart_type: Literal["handlebars"] = Field( + ..., + description=( + "Chart type discriminator - MUST be 'handlebars' for custom HTML " + "template charts. Handlebars charts render query results using " + "Handlebars templates, enabling fully custom layouts like KPI cards, " + "leaderboards, and formatted reports." + ), + ) + handlebars_template: str = Field( + ..., + description=( + "Handlebars HTML template string. Data is available as {{data}} array. " + "Built-in helpers: {{dateFormat val format='YYYY-MM-DD'}}, " + "{{formatNumber val}}, {{stringify obj}}. " + "Example: ''" + ), + min_length=1, + max_length=50000, + ) + query_mode: Literal["aggregate", "raw"] = Field( + "aggregate", + description=( + "Query mode: 'aggregate' groups data with metrics, " + "'raw' returns individual rows" + ), + ) + columns: list[ColumnRef] | None = Field( + None, + description=( + "Columns to display in raw mode (query_mode='raw'). " + "Each column specifies a column name to include in the query results." + ), + ) + groupby: list[ColumnRef] | None = Field( + None, + description=( + "Columns to group by in aggregate mode (query_mode='aggregate'). " + "These become the dimensions for aggregation." + ), + ) + metrics: list[ColumnRef] | None = Field( + None, + description=( + "Metrics to aggregate in aggregate mode. " + "Each must have an aggregate function (e.g., SUM, COUNT)." + ), + ) + filters: list[FilterConfig] | None = Field(None, description="Filters to apply") + row_limit: int = Field( + 1000, + description="Maximum number of rows", + ge=1, + le=50000, + ) + order_desc: bool = Field(True, description="Sort in descending order") + style_template: str | None = Field( + None, + description="Optional CSS styles to apply to the rendered template", + max_length=10000, + ) + + @model_validator(mode="after") + def validate_query_fields(self) -> "HandlebarsChartConfig": + """Validate that the right fields are provided for the query mode.""" + if self.query_mode == "raw": + if not self.columns: + raise ValueError( + "Handlebars chart in 'raw' query mode requires 'columns' field. " + "Specify which columns to include in the query results." + ) + if self.metrics: + raise ValueError( + "Handlebars chart in 'raw' query mode does not use 'metrics'. " + "Remove 'metrics' or switch to 'aggregate' query mode." + ) + if self.groupby: + raise ValueError( + "Handlebars chart in 'raw' query mode does not use 'groupby'. " + "Remove 'groupby' or switch to 'aggregate' query mode." + ) + if self.query_mode == "aggregate": + if not self.metrics: + raise ValueError( + "Handlebars chart in 'aggregate' query mode requires 'metrics' " + "field. Specify at least one metric with an aggregate function." + ) + missing_agg = [m.name for m in self.metrics if not m.aggregate] + if missing_agg: + raise ValueError( + f"Handlebars chart in 'aggregate' query mode requires an " + f"aggregate function on every metric. Missing aggregate for: " + f"{', '.join(missing_agg)}. " + f"Use one of: SUM, COUNT, AVG, MIN, MAX, COUNT_DISTINCT, etc." + ) + return self + + class TableChartConfig(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -810,12 +912,13 @@ ChartConfig = Annotated[ | TableChartConfig | PieChartConfig | PivotTableChartConfig - | MixedTimeseriesChartConfig, + | MixedTimeseriesChartConfig + | HandlebarsChartConfig, Field( discriminator="chart_type", description=( "Chart configuration - specify chart_type as 'xy', 'table', " - "'pie', 'pivot_table', or 'mixed_timeseries'" + "'pie', 'pivot_table', 'mixed_timeseries', or 'handlebars'" ), ), ] diff --git a/superset/mcp_service/chart/validation/schema_validator.py b/superset/mcp_service/chart/validation/schema_validator.py index aa50d4c843e..07ecb2b912b 100644 --- a/superset/mcp_service/chart/validation/schema_validator.py +++ b/superset/mcp_service/chart/validation/schema_validator.py @@ -133,6 +133,8 @@ class SchemaValidator: "Add 'chart_type': 'pie' for pie or donut charts", "Add 'chart_type': 'pivot_table' for interactive pivot tables", "Add 'chart_type': 'mixed_timeseries' for dual-series time charts", + "Add 'chart_type': 'handlebars' for custom HTML template charts", + "Example: 'config': {'chart_type': 'xy', ...}", ], error_code="MISSING_CHART_TYPE", ) @@ -151,6 +153,7 @@ class SchemaValidator: "pie": SchemaValidator._pre_validate_pie_config, "pivot_table": SchemaValidator._pre_validate_pivot_table_config, "mixed_timeseries": SchemaValidator._pre_validate_mixed_timeseries_config, + "handlebars": SchemaValidator._pre_validate_handlebars_config, } if not isinstance(chart_type, str) or chart_type not in chart_type_validators: @@ -166,6 +169,7 @@ class SchemaValidator: "Use 'chart_type': 'pie' for pie or donut charts", "Use 'chart_type': 'pivot_table' for interactive pivot tables", "Use 'chart_type': 'mixed_timeseries' for dual-series time charts", + "Use 'chart_type': 'handlebars' for custom HTML template charts", "Check spelling and ensure lowercase", ], error_code="INVALID_CHART_TYPE", @@ -282,6 +286,82 @@ class SchemaValidator: return True, None + @staticmethod + def _pre_validate_handlebars_config( + config: Dict[str, Any], + ) -> Tuple[bool, ChartGenerationError | None]: + """Pre-validate handlebars chart configuration.""" + if "handlebars_template" not in config: + return False, ChartGenerationError( + error_type="missing_handlebars_template", + message="Handlebars chart missing required field: handlebars_template", + details="Handlebars charts require a 'handlebars_template' string " + "containing Handlebars HTML template markup", + suggestions=[ + "Add 'handlebars_template' with a Handlebars HTML template", + "Data is available as {{data}} array in the template", + "Example: ''", + ], + error_code="MISSING_HANDLEBARS_TEMPLATE", + ) + + template = config.get("handlebars_template") + if not isinstance(template, str) or not template.strip(): + return False, ChartGenerationError( + error_type="invalid_handlebars_template", + message="Handlebars template must be a non-empty string", + details="The 'handlebars_template' field must be a non-empty string " + "containing valid Handlebars HTML template markup", + suggestions=[ + "Ensure handlebars_template is a non-empty string", + "Example: ''", + ], + error_code="INVALID_HANDLEBARS_TEMPLATE", + ) + + query_mode = config.get("query_mode", "aggregate") + if query_mode not in ("aggregate", "raw"): + return False, ChartGenerationError( + error_type="invalid_query_mode", + message="Invalid query_mode for handlebars chart", + details="query_mode must be either 'aggregate' or 'raw'", + suggestions=[ + "Use 'aggregate' for aggregated data (default)", + "Use 'raw' for individual rows", + ], + error_code="INVALID_QUERY_MODE", + ) + + if query_mode == "raw" and not config.get("columns"): + return False, ChartGenerationError( + error_type="missing_raw_columns", + message="Handlebars chart in 'raw' mode requires 'columns'", + details="When query_mode is 'raw', you must specify which columns " + "to include in the query results", + suggestions=[ + "Add 'columns': [{'name': 'column_name'}] for raw mode", + "Or use query_mode='aggregate' with 'metrics' " + "and optional 'groupby'", + ], + error_code="MISSING_RAW_COLUMNS", + ) + + if query_mode == "aggregate" and not config.get("metrics"): + return False, ChartGenerationError( + error_type="missing_aggregate_metrics", + message="Handlebars chart in 'aggregate' mode requires 'metrics'", + details="When query_mode is 'aggregate' (default), you must specify " + "at least one metric with an aggregate function", + suggestions=[ + "Add 'metrics': [{'name': 'column', 'aggregate': 'SUM'}]", + "Or use query_mode='raw' with 'columns' for individual rows", + ], + error_code="MISSING_AGGREGATE_METRICS", + ) + + return True, None + @staticmethod def _pre_validate_pivot_table_config( config: Dict[str, Any], @@ -428,6 +508,24 @@ class SchemaValidator: ], error_code="TABLE_VALIDATION_ERROR", ) + elif chart_type == "handlebars": + return ChartGenerationError( + error_type="handlebars_validation_error", + message="Handlebars chart configuration validation failed", + details="The handlebars chart configuration is missing " + "required fields or has invalid structure", + suggestions=[ + "Ensure 'handlebars_template' is a non-empty string", + "For aggregate mode: add 'metrics' with aggregate " + "functions", + "For raw mode: set 'query_mode': 'raw' and add 'columns'", + "Example: {'chart_type': 'handlebars', " + "'handlebars_template': '', " + "'metrics': [{'name': 'sales', 'aggregate': 'SUM'}]}", + ], + error_code="HANDLEBARS_VALIDATION_ERROR", + ) # Default enhanced error error_details = [] diff --git a/tests/unit_tests/mcp_service/chart/test_handlebars_chart.py b/tests/unit_tests/mcp_service/chart/test_handlebars_chart.py new file mode 100644 index 00000000000..ed0317265fc --- /dev/null +++ b/tests/unit_tests/mcp_service/chart/test_handlebars_chart.py @@ -0,0 +1,565 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for Handlebars chart type support in MCP service.""" + +from unittest.mock import MagicMock + +import pytest + +from superset.mcp_service.chart.chart_utils import ( + _resolve_viz_type, + analyze_chart_capabilities, + analyze_chart_semantics, + generate_chart_name, + map_config_to_form_data, + map_handlebars_config, +) +from superset.mcp_service.chart.schemas import ( + ColumnRef, + FilterConfig, + HandlebarsChartConfig, +) +from superset.mcp_service.chart.validation.schema_validator import SchemaValidator + + +class TestHandlebarsChartConfig: + """Test HandlebarsChartConfig Pydantic schema.""" + + def test_minimal_aggregate_config(self) -> None: + template = "" + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template=template, + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + assert config.chart_type == "handlebars" + assert config.query_mode == "aggregate" + assert config.row_limit == 1000 + + def test_minimal_raw_config(self) -> None: + template = ( + "{{#each data}}{{/each}}
{{this.name}}
" + ) + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template=template, + query_mode="raw", + columns=[ColumnRef(name="product"), ColumnRef(name="price")], + ) + assert config.query_mode == "raw" + assert config.columns is not None + assert len(config.columns) == 2 + + def test_aggregate_mode_requires_metrics(self) -> None: + with pytest.raises(ValueError, match="requires 'metrics'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="aggregate", + ) + + def test_raw_mode_requires_columns(self) -> None: + with pytest.raises(ValueError, match="requires 'columns'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="raw", + ) + + def test_template_min_length(self) -> None: + with pytest.raises(ValueError, match="at least 1 character"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + + def test_raw_mode_rejects_metrics(self) -> None: + with pytest.raises(ValueError, match="does not use 'metrics'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="raw", + columns=[ColumnRef(name="product")], + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + + def test_raw_mode_rejects_groupby(self) -> None: + with pytest.raises(ValueError, match="does not use 'groupby'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="raw", + columns=[ColumnRef(name="product")], + groupby=[ColumnRef(name="region")], + ) + + def test_aggregate_mode_requires_aggregate_function(self) -> None: + with pytest.raises(ValueError, match="Missing aggregate for"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="aggregate", + metrics=[ColumnRef(name="sales")], + ) + + def test_extra_fields_forbidden(self) -> None: + with pytest.raises(ValueError, match="Extra inputs"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + unknown_field="bad", + ) + + def test_full_aggregate_config(self) -> None: + template = ( + "
{{#each data}}" + "{{this.region}}: {{this.total}}" + "{{/each}}
" + ) + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template=template, + query_mode="aggregate", + groupby=[ColumnRef(name="region")], + metrics=[ColumnRef(name="sales", aggregate="SUM", label="total")], + filters=[FilterConfig(column="status", op="=", value="active")], + row_limit=500, + order_desc=False, + style_template="div { color: blue; }", + ) + assert config.row_limit == 500 + assert config.order_desc is False + assert config.style_template == "div { color: blue; }" + assert config.filters is not None + assert len(config.filters) == 1 + assert config.groupby is not None + assert len(config.groupby) == 1 + + def test_row_limit_too_low(self) -> None: + with pytest.raises(ValueError, match="greater than or equal to 1"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + row_limit=0, + ) + + def test_row_limit_too_high(self) -> None: + with pytest.raises(ValueError, match="less than or equal to 50000"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + row_limit=50001, + ) + + +class TestMapHandlebarsConfig: + """Test map_handlebars_config function.""" + + def test_aggregate_mode_basic(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

{{data}}

", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + result = map_handlebars_config(config) + + assert result["viz_type"] == "handlebars" + assert result["handlebars_template"] == "

{{data}}

" + assert result["query_mode"] == "aggregate" + assert result["row_limit"] == 1000 + assert result["order_desc"] is True + assert len(result["metrics"]) == 1 + assert result["metrics"][0]["aggregate"] == "SUM" + + def test_aggregate_mode_with_groupby(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

{{data}}

", + groupby=[ColumnRef(name="region")], + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + result = map_handlebars_config(config) + + assert result["groupby"] == ["region"] + + def test_raw_mode(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="rows
", + query_mode="raw", + columns=[ColumnRef(name="product"), ColumnRef(name="price")], + ) + result = map_handlebars_config(config) + + assert result["query_mode"] == "raw" + assert result["all_columns"] == ["product", "price"] + assert "metrics" not in result + assert "groupby" not in result + + def test_with_filters(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="count", aggregate="COUNT")], + filters=[FilterConfig(column="status", op="=", value="active")], + ) + result = map_handlebars_config(config) + + assert "adhoc_filters" in result + assert len(result["adhoc_filters"]) == 1 + assert result["adhoc_filters"][0]["subject"] == "status" + assert result["adhoc_filters"][0]["operator"] == "==" + assert result["adhoc_filters"][0]["comparator"] == "active" + + def test_with_style_template(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + style_template="p { font-size: 24px; }", + ) + result = map_handlebars_config(config) + + assert result["styleTemplate"] == "p { font-size: 24px; }" + + def test_without_style_template(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + ) + result = map_handlebars_config(config) + + assert "styleTemplate" not in result + + def test_custom_row_limit_and_order(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + row_limit=50, + order_desc=False, + ) + result = map_handlebars_config(config) + + assert result["row_limit"] == 50 + assert result["order_desc"] is False + + +class TestMapConfigToFormDataHandlebars: + """Test map_config_to_form_data dispatches to handlebars correctly.""" + + def test_dispatches_handlebars_config(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + result = map_config_to_form_data(config) + assert result["viz_type"] == "handlebars" + + +class TestGenerateChartNameHandlebars: + """Test generate_chart_name for handlebars configs.""" + + def test_raw_mode_with_columns(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="raw", + columns=[ColumnRef(name="product"), ColumnRef(name="price")], + ) + name = generate_chart_name(config) + assert "Handlebars" in name + assert "product" in name + assert "price" in name + + def test_aggregate_mode_with_metrics(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + name = generate_chart_name(config) + assert "Handlebars" in name + assert "sales" in name + + def test_aggregate_mode_no_groupby(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="count", aggregate="COUNT")], + ) + name = generate_chart_name(config) + assert "Handlebars" in name + assert "count" in name + + def test_truncates_to_three_columns(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + query_mode="raw", + columns=[ + ColumnRef(name="alpha"), + ColumnRef(name="bravo"), + ColumnRef(name="charlie"), + ColumnRef(name="delta"), + ], + ) + name = generate_chart_name(config) + assert "alpha" in name + assert "bravo" in name + assert "charlie" in name + assert "delta" not in name + + +class TestResolveVizType: + """Test _resolve_viz_type helper.""" + + def test_xy_line(self) -> None: + config = MagicMock(chart_type="xy", kind="line") + assert _resolve_viz_type(config) == "echarts_timeseries_line" + + def test_xy_bar(self) -> None: + config = MagicMock(chart_type="xy", kind="bar") + assert _resolve_viz_type(config) == "echarts_timeseries_bar" + + def test_xy_area(self) -> None: + config = MagicMock(chart_type="xy", kind="area") + assert _resolve_viz_type(config) == "echarts_area" + + def test_xy_scatter(self) -> None: + config = MagicMock(chart_type="xy", kind="scatter") + assert _resolve_viz_type(config) == "echarts_timeseries_scatter" + + def test_table(self) -> None: + config = MagicMock(chart_type="table", viz_type="table") + assert _resolve_viz_type(config) == "table" + + def test_ag_grid_table(self) -> None: + config = MagicMock(chart_type="table", viz_type="ag-grid-table") + assert _resolve_viz_type(config) == "ag-grid-table" + + def test_handlebars(self) -> None: + config = MagicMock(chart_type="handlebars") + assert _resolve_viz_type(config) == "handlebars" + + def test_unknown(self) -> None: + config = MagicMock(chart_type="unknown_type") + assert _resolve_viz_type(config) == "unknown" + + +class TestAnalyzeChartCapabilitiesHandlebars: + """Test analyze_chart_capabilities for handlebars config.""" + + def test_handlebars_capabilities(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + caps = analyze_chart_capabilities(None, config) + + assert caps.supports_interaction is False + assert caps.supports_drill_down is False + assert caps.supports_export is True + assert "url" in caps.optimal_formats + + +class TestAnalyzeChartSemanticsHandlebars: + """Test analyze_chart_semantics for handlebars config.""" + + def test_handlebars_semantics(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="

test

", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + semantics = analyze_chart_semantics(None, config) + + assert ( + "Handlebars" in semantics.primary_insight + or "template" in semantics.primary_insight + ) + assert semantics.data_story is not None + + +class TestSchemaValidatorHandlebars: + """Test SchemaValidator pre-validation for handlebars chart type.""" + + def test_handlebars_accepted_as_valid_chart_type(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "

{{data}}

", + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None + assert error is None + + def test_missing_handlebars_template(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_HANDLEBARS_TEMPLATE" + + def test_empty_handlebars_template(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": " ", + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_HANDLEBARS_TEMPLATE" + + def test_non_string_handlebars_template(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": 123, + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_HANDLEBARS_TEMPLATE" + + def test_invalid_query_mode_rejected(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "

test

", + "query_mode": "invalid", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_QUERY_MODE" + + def test_raw_mode_missing_columns(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "

test

", + "query_mode": "raw", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_RAW_COLUMNS" + + def test_aggregate_mode_missing_metrics(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "

test

", + "query_mode": "aggregate", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_AGGREGATE_METRICS" + + def test_default_aggregate_mode_missing_metrics(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "

test

", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_AGGREGATE_METRICS" + + def test_handlebars_raw_mode_valid(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "

{{#each data}}{{this.name}}{{/each}}

", + "query_mode": "raw", + "columns": [{"name": "product"}, {"name": "price"}], + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None + assert error is None + + def test_non_string_chart_type_rejected(self) -> None: + data = { + "dataset_id": 1, + "config": {"chart_type": ["handlebars"]}, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_CHART_TYPE" + + def test_handlebars_in_error_messages(self) -> None: + """Verify 'handlebars' appears in missing chart_type suggestions.""" + data = { + "dataset_id": 1, + "config": {}, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + suggestions_text = " ".join(error.suggestions or []) + assert "handlebars" in suggestions_text + + def test_invalid_chart_type_mentions_handlebars(self) -> None: + """Invalid chart_type error should mention handlebars as option.""" + data = { + "dataset_id": 1, + "config": {"chart_type": "invalid"}, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert "handlebars" in (error.details or "")