From a1b4725fc4213433433791af71ec98b2405b5bcd Mon Sep 17 00:00:00 2001 From: Bruin Agent Date: Tue, 7 Jul 2026 13:07:59 +0000 Subject: [PATCH] Add per-series chart pattern styles --- docs/dashboards/tsx.md | 17 + docs/dashboards/widgets.md | 55 ++- .../src/components/widgets/ChartWidget.tsx | 360 ++++++++++++++---- frontend/src/types/dashboard.ts | 9 + pkg/dashboard/jsloader.go | 62 ++- pkg/dashboard/model.go | 46 ++- pkg/dashboard/series_style_test.go | 112 ++++++ pkg/dashboard/validator.go | 37 ++ schemas/dac/dashboard/v1/schema.json | 26 ++ 9 files changed, 601 insertions(+), 123 deletions(-) create mode 100644 pkg/dashboard/series_style_test.go diff --git a/docs/dashboards/tsx.md b/docs/dashboards/tsx.md index 37a2fe8..7b2d2ac 100644 --- a/docs/dashboards/tsx.md +++ b/docs/dashboards/tsx.md @@ -188,6 +188,23 @@ All widget components accept the same props as their YAML equivalents. Logo ``` +Series pattern styles use the same `seriesStyles` object as YAML: + +```tsx + +``` + ## How It Works DAC: diff --git a/docs/dashboards/widgets.md b/docs/dashboards/widgets.md index f116f66..1914b83 100644 --- a/docs/dashboards/widgets.md +++ b/docs/dashboards/widgets.md @@ -77,9 +77,9 @@ Charts visualize one or more series. The `chart` field selects the chart type an | Chart | Required | Optional | Description | |-------|----------|----------|-------------| -| `line` | `x`, `y` | `color` | Line chart | -| `bar` | `x`, `y` | `color`, `stacked`, `normalized`, `horizontal` | Bar chart | -| `area` | `x`, `y` | `color` | Area chart | +| `line` | `x`, `y` | `color`, `seriesStyles` | Line chart | +| `bar` | `x`, `y` | `color`, `stacked`, `normalized`, `horizontal`, `seriesStyles` | Bar chart | +| `area` | `x`, `y` | `color`, `seriesStyles` | Area chart | | `pie` | `label`, `value` | | Pie/donut chart | | `scatter` | `x`, `y` | | Scatter plot | | `bubble` | `x`, `y`, `size` | | Bubble chart | @@ -154,6 +154,54 @@ Rules: - `normalized: true` shows each stacked bar as percentages of the row total; the y axis renders `%` automatically, so omit `y.format`. - `horizontal: true` flips a bar chart: categories run down the vertical axis. +### Series styles + +`seriesStyles` assigns non-color visual encodings to rendered series. Keys match the rendered series name: + +- For wide data with `y: { field: [actual, forecast] }`, use the y field names. +- For long data with `color: { field: region }`, use the category values produced by `region`. + +Supported styles: + +| Key | Values | Rendered on | +|-----|--------|-------------| +| `lineStyle` | `solid`, `dashed`, `dotted` | line, area, combo line, sparkline, radar strokes | +| `fillStyle` | `solid`, `striped`, `hatched` | bar, area, combo bar, radar fills | + +```yaml +- name: Revenue vs Forecast + type: chart + chart: line + sql: | + SELECT month, actual_revenue, forecast_revenue + FROM revenue_by_month + ORDER BY month + x: { field: month } + y: { field: [actual_revenue, forecast_revenue] } + seriesStyles: + actual_revenue: + lineStyle: solid + forecast_revenue: + lineStyle: dashed +``` + +```yaml +- name: Regional Mix + type: chart + chart: bar + sql: | + SELECT region, current_share, prior_share + FROM regional_mix + ORDER BY current_share DESC + x: { field: region } + y: { field: [current_share, prior_share] } + seriesStyles: + current_share: + fillStyle: solid + prior_share: + fillStyle: striped +``` + Semantic example: ```yaml @@ -186,6 +234,7 @@ Common chart fields: | `low` | string | Low column for candlestick charts | | `close` | string | Close column for candlestick charts | | `color` | object | Category column (`{ field: ... }`) that splits the single `y` series into one series per category — bar, line, and area charts | +| `seriesStyles` | object | Per-series `lineStyle` and `fillStyle` overrides for pattern-based visual encoding | | `stacked` | boolean | Stack the color series (bar charts only; requires `color`) | | `normalized` | boolean | Render stacked bars as percentages of the row total (requires `stacked`) | | `horizontal` | boolean | Horizontal bars: categories on the vertical axis (bar charts only) | diff --git a/frontend/src/components/widgets/ChartWidget.tsx b/frontend/src/components/widgets/ChartWidget.tsx index ba77071..10061e0 100644 --- a/frontend/src/components/widgets/ChartWidget.tsx +++ b/frontend/src/components/widgets/ChartWidget.tsx @@ -1,4 +1,4 @@ -import { useContext, useMemo, useState } from "react"; +import { useContext, useId, useMemo, useState } from "react"; import { LineChart, Line, BarChart, Bar, AreaChart, Area, PieChart, Pie, Cell, ScatterChart, Scatter, ZAxis, @@ -8,7 +8,7 @@ import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, } from "recharts"; import type { TreemapNode } from "recharts"; -import type { Widget, WidgetData } from "../../types/dashboard"; +import type { SeriesStyle, Widget, WidgetData } from "../../types/dashboard"; import { axisField, axisFields, buildAxisFormatter, valueField } from "../../lib/format"; import { useTokens } from "../../themes/TemplateProvider"; import { RowHeightContext } from "../../themes/RowContext"; @@ -77,6 +77,112 @@ const CHART_COLORS = [ const AXIS_STYLE = { fontSize: 11, fontFamily: '"Geist", system-ui' }; +type SeriesStyles = Record; +type SeriesMarkerMode = "line" | "fill" | "area"; +type SeriesMarkerModes = Record; + +const LINE_DASHARRAYS = { + solid: undefined, + dashed: "6 4", + dotted: "1 4", +} satisfies Record, string | undefined>; + +function getLineDasharray(style?: SeriesStyle): string | undefined { + if (!style?.lineStyle) return undefined; + return LINE_DASHARRAYS[style.lineStyle]; +} + +function getFillPattern(style?: SeriesStyle): "striped" | "hatched" | undefined { + if (style?.fillStyle === "striped" || style?.fillStyle === "hatched") { + return style.fillStyle; + } + return undefined; +} + +function svgSafeId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function patternId(baseId: string, series: string): string { + return `dac-pattern-${baseId}-${svgSafeId(series)}`; +} + +function fillForSeries(baseId: string, series: string, color: string, style?: SeriesStyle): string { + return getFillPattern(style) ? `url(#${patternId(baseId, series)})` : color; +} + +function seriesColorMap(series: string[], colors: string[]): Record { + return Object.fromEntries(series.map((field, i) => [field, colors[i % colors.length]])); +} + +function markerModeForSeries(chartType: Widget["chart"], field: string, modes?: SeriesMarkerModes): SeriesMarkerMode { + if (modes?.[field]) return modes[field]; + if (chartType === "line" || chartType === "sparkline") return "line"; + if (chartType === "area" || chartType === "radar") return "area"; + return "fill"; +} + +function SeriesPatternDefs({ + baseId, + series, + colors, + seriesStyles, +}: { + baseId: string; + series: string[]; + colors: string[]; + seriesStyles?: SeriesStyles; +}) { + const patternedSeries = series.filter((field) => getFillPattern(seriesStyles?.[field])); + if (patternedSeries.length === 0) return null; + + return ( + + {patternedSeries.map((field) => { + const color = colors[series.indexOf(field) % colors.length]; + const fillPattern = getFillPattern(seriesStyles?.[field]); + return ( + + + + {fillPattern === "hatched" && ( + + )} + + ); + })} + + ); +} + +function SeriesMarker({ color, style, mode }: { color: string; style?: SeriesStyle; mode: SeriesMarkerMode }) { + const dasharray = getLineDasharray(style); + const fillPattern = getFillPattern(style); + + if (mode === "line") { + return ( + + ); + } + + return ( + + ); +} + interface TooltipPayloadEntry { color?: string; fill?: string; @@ -85,28 +191,73 @@ interface TooltipPayloadEntry { value?: unknown; } -function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick, valueFormatter = formatTooltipValue }: { +function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick, valueFormatter = formatTooltipValue, seriesStyles, chartType, seriesColors, seriesModes }: { active?: boolean; payload?: TooltipPayloadEntry[]; label?: unknown; labelFormatter?: (val: unknown) => string; valueFormatter?: (val: unknown) => string; + seriesStyles?: SeriesStyles; + chartType?: Widget["chart"]; + seriesColors?: Record; + seriesModes?: SeriesMarkerModes; }) { if (!active || !payload?.length) return null; return (
{labelFormatter(label)}
- {payload.map((p, i) => ( -
- - {p.name ?? p.dataKey} - {valueFormatter(p.value)} -
- ))} + {payload.map((p, i) => { + const field = String(p.dataKey ?? p.name ?? ""); + const color = seriesColors?.[field] ?? p.color ?? p.fill ?? "var(--dac-accent)"; + return ( +
+ + {p.name ?? p.dataKey} + {valueFormatter(p.value)} +
+ ); + })}
); } +interface LegendPayloadEntry { + color?: string; + value?: string; + dataKey?: string; +} + +function CustomLegend({ + payload, + seriesStyles, + chartType, + seriesColors, + seriesModes, +}: { + payload?: LegendPayloadEntry[]; + seriesStyles?: SeriesStyles; + chartType?: Widget["chart"]; + seriesColors?: Record; + seriesModes?: SeriesMarkerModes; +}) { + if (!payload?.length) return null; + + return ( +
    + {payload.map((entry, i) => { + const field = String(entry.dataKey ?? entry.value ?? ""); + const color = seriesColors?.[field] ?? entry.color ?? "var(--dac-accent)"; + return ( +
  • + + {entry.value ?? entry.dataKey} +
  • + ); + })} +
+ ); +} + // --- Color (long-format) pivot --- /** @@ -834,6 +985,7 @@ export function ChartWidget({ widget, data }: Props) { function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: number }) { const tokens = useTokens(); const rowHeight = useContext(RowHeightContext); + const patternBaseId = svgSafeId(useId()); const chartHeight = (rowHeight !== undefined ? Math.max(80, rowHeight - FRAME_OVERHEAD) : DEFAULT_CHART_HEIGHT) - titleOffset; @@ -877,26 +1029,32 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu const pivoted = colorKey && xKey && yKeys[0] ? pivotByColor(chartData, xKey, yKeys[0], colorKey) : null; const rows = pivoted ? pivoted.rows : chartData; const series = pivoted ? pivoted.series : yKeys; + const seriesColors = seriesColorMap(series, colors); return ( - - {series.length > 1 && } - {series.map((field, i) => ( - - ))} + } /> + {series.length > 1 && } />} + {series.map((field, i) => { + const style = widget.seriesStyles?.[field]; + return ( + + ); + })} ); @@ -914,9 +1072,11 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu const valueTick = widget.normalized ? formatPercentTick : yTick; const valueTooltip = widget.normalized ? formatPercentTick : yTooltipValue; const lastRadius: [number, number, number, number] = horizontal ? [0, 2, 2, 0] : [2, 2, 0, 0]; + const seriesColors = seriesColorMap(series, colors); return ( + {horizontal ? ( <> @@ -929,18 +1089,22 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu )} - } cursor={{ fill: gridColor, fillOpacity: 0.2 }} /> - {series.length > 1 && } - {series.map((field, i) => ( - - ))} + } cursor={{ fill: gridColor, fillOpacity: 0.2 }} /> + {series.length > 1 && } />} + {series.map((field, i) => { + const color = colors[i % colors.length]; + const style = widget.seriesStyles?.[field]; + return ( + + ); + })} ); @@ -951,26 +1115,34 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu const pivoted = colorKey && xKey && yKeys[0] ? pivotByColor(chartData, xKey, yKeys[0], colorKey) : null; const rows = pivoted ? pivoted.rows : chartData; const series = pivoted ? pivoted.series : yKeys; + const seriesColors = seriesColorMap(series, colors); return ( + - - {series.length > 1 && } - {series.map((field, i) => ( - - ))} + } /> + {series.length > 1 && } />} + {series.map((field, i) => { + const color = colors[i % colors.length]; + const style = widget.seriesStyles?.[field]; + return ( + + ); + })} ); @@ -1057,21 +1229,28 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu case "combo": { const yFields = yKeys; const lineSet = new Set(widget.lines ?? []); + const seriesColors = seriesColorMap(yFields, colors); + const seriesModes = Object.fromEntries(yFields.map((field) => [field, lineSet.has(field) ? "line" : "fill"])) as SeriesMarkerModes; return ( + - - - {yFields.map((field, i) => - lineSet.has(field) ? ( + } /> + } /> + {yFields.map((field, i) => { + const color = colors[i % colors.length]; + const style = widget.seriesStyles?.[field]; + return lineSet.has(field) ? ( - ), - )} + ); + })} ); @@ -1203,18 +1382,23 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu return ( - {yKeys.map((field, i) => ( - - ))} - } /> + {yKeys.map((field, i) => { + const style = widget.seriesStyles?.[field]; + return ( + + ); + })} + } /> ); @@ -1318,26 +1502,34 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu case "radar": { const yFields = yKeys; + const seriesColors = seriesColorMap(yFields, colors); return ( + - } /> - {yFields.length > 1 && } - {yFields.map((field, i) => ( - - ))} + } /> + {yFields.length > 1 && } />} + {yFields.map((field, i) => { + const color = colors[i % colors.length]; + const style = widget.seriesStyles?.[field]; + return ( + + ); + })} ); diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index f569abc..421f0da 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -81,6 +81,7 @@ export interface Widget { value?: ValueEncoding; // bar/line/area: split the single y series by a category column (long-format SQL) color?: ColorEncoding; + seriesStyles?: Record; stacked?: boolean; // bar only; requires color normalized?: boolean; // stacked bars as percentages horizontal?: boolean; // bar only @@ -126,6 +127,14 @@ export interface ColorEncoding { field: string; } +export type LineStyle = "solid" | "dashed" | "dotted"; +export type FillStyle = "solid" | "striped" | "hatched"; + +export interface SeriesStyle { + lineStyle?: LineStyle; + fillStyle?: FillStyle; +} + /** Structured encoding for a widget's value channel (metric value, pie/funnel/gauge value column). */ export interface ValueEncoding { field: string; diff --git a/pkg/dashboard/jsloader.go b/pkg/dashboard/jsloader.go index b9070ba..31946ba 100644 --- a/pkg/dashboard/jsloader.go +++ b/pkg/dashboard/jsloader.go @@ -523,22 +523,23 @@ func vnodeToWidget(n *vnode) Widget { Limit: asInt(n.Props["limit"]), // Chart fields - Chart: asString(n.Props["chart"]), - X: asAxisEncoding(n.Props["x"]), - Y: asAxisEncoding(n.Props["y"]), - Label: asString(n.Props["label"]), - Value: asValueEncoding(n.Props["value"]), - Color: asColorEncoding(n.Props["color"]), - Stacked: asBool(n.Props["stacked"]), - Normalized: asBool(n.Props["normalized"]), - Horizontal: asBool(n.Props["horizontal"]), - Size: asString(n.Props["size"]), - Source: asString(n.Props["source"]), - Target: asString(n.Props["target"]), - Bins: asInt(n.Props["bins"]), - Lines: asStringSlice(n.Props["lines"]), - YMin: asString(n.Props["yMin"]), - YMax: asString(n.Props["yMax"]), + Chart: asString(n.Props["chart"]), + X: asAxisEncoding(n.Props["x"]), + Y: asAxisEncoding(n.Props["y"]), + Label: asString(n.Props["label"]), + Value: asValueEncoding(n.Props["value"]), + Color: asColorEncoding(n.Props["color"]), + SeriesStyles: asSeriesStyles(n.Props["seriesStyles"]), + Stacked: asBool(n.Props["stacked"]), + Normalized: asBool(n.Props["normalized"]), + Horizontal: asBool(n.Props["horizontal"]), + Size: asString(n.Props["size"]), + Source: asString(n.Props["source"]), + Target: asString(n.Props["target"]), + Bins: asInt(n.Props["bins"]), + Lines: asStringSlice(n.Props["lines"]), + YMin: asString(n.Props["yMin"]), + YMax: asString(n.Props["yMax"]), // Table fields Columns: asTableColumns(n.Props["columns"]), @@ -721,6 +722,35 @@ func asColorEncoding(v interface{}) *ColorEncoding { return &ColorEncoding{Field: field} } +func asSeriesStyles(v interface{}) map[string]SeriesStyle { + if v == nil { + return nil + } + raw, ok := v.(map[string]interface{}) + if !ok { + return nil + } + out := make(map[string]SeriesStyle, len(raw)) + for series, value := range raw { + m, ok := value.(map[string]interface{}) + if !ok { + continue + } + style := SeriesStyle{ + LineStyle: asString(m["lineStyle"]), + FillStyle: asString(m["fillStyle"]), + } + if style.LineStyle == "" && style.FillStyle == "" { + continue + } + out[series] = style + } + if len(out) == 0 { + return nil + } + return out +} + func asStringMap(v interface{}) map[string]string { if v == nil { return nil diff --git a/pkg/dashboard/model.go b/pkg/dashboard/model.go index ba2d03a..c633446 100644 --- a/pkg/dashboard/model.go +++ b/pkg/dashboard/model.go @@ -114,26 +114,27 @@ type Widget struct { Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` // LIMIT for dimensional queries // Chart fields - Chart string `yaml:"chart,omitempty" json:"chart,omitempty"` // line, bar, area, pie, scatter, bubble, combo, histogram, boxplot, funnel, sankey, heatmap, calendar, sparkline, waterfall, xmr, dumbbell, gauge, treemap, radar, candlestick - X *AxisEncoding `yaml:"x,omitempty" json:"x,omitempty"` - Y *AxisEncoding `yaml:"y,omitempty" json:"y,omitempty"` - Label string `yaml:"label,omitempty" json:"label,omitempty"` // for pie/funnel/treemap - Value *ValueEncoding `yaml:"value,omitempty" json:"value,omitempty"` // metric: the value; pie/funnel/heatmap/calendar/treemap/gauge: value column - Color *ColorEncoding `yaml:"color,omitempty" json:"color,omitempty"` - Stacked bool `yaml:"stacked,omitempty" json:"stacked,omitempty"` - Normalized bool `yaml:"normalized,omitempty" json:"normalized,omitempty"` - Horizontal bool `yaml:"horizontal,omitempty" json:"horizontal,omitempty"` - Size string `yaml:"size,omitempty" json:"size,omitempty"` - Source string `yaml:"source,omitempty" json:"source,omitempty"` // sankey: source column - Target string `yaml:"target,omitempty" json:"target,omitempty"` // sankey: target column, gauge: target (max) column - Bins int `yaml:"bins,omitempty" json:"bins,omitempty"` // histogram: number of bins - Lines []string `yaml:"lines,omitempty" json:"lines,omitempty"` // combo: which y series render as lines - YMin string `yaml:"yMin,omitempty" json:"yMin,omitempty"` // xmr: min control limit column - YMax string `yaml:"yMax,omitempty" json:"yMax,omitempty"` // xmr: max control limit column - Open string `yaml:"open,omitempty" json:"open,omitempty"` // candlestick: open price column - High string `yaml:"high,omitempty" json:"high,omitempty"` // candlestick: high price column - Low string `yaml:"low,omitempty" json:"low,omitempty"` // candlestick: low price column - Close string `yaml:"close,omitempty" json:"close,omitempty"` // candlestick: close price column + Chart string `yaml:"chart,omitempty" json:"chart,omitempty"` // line, bar, area, pie, scatter, bubble, combo, histogram, boxplot, funnel, sankey, heatmap, calendar, sparkline, waterfall, xmr, dumbbell, gauge, treemap, radar, candlestick + X *AxisEncoding `yaml:"x,omitempty" json:"x,omitempty"` + Y *AxisEncoding `yaml:"y,omitempty" json:"y,omitempty"` + Label string `yaml:"label,omitempty" json:"label,omitempty"` // for pie/funnel/treemap + Value *ValueEncoding `yaml:"value,omitempty" json:"value,omitempty"` // metric: the value; pie/funnel/heatmap/calendar/treemap/gauge: value column + Color *ColorEncoding `yaml:"color,omitempty" json:"color,omitempty"` + SeriesStyles map[string]SeriesStyle `yaml:"seriesStyles,omitempty" json:"seriesStyles,omitempty"` + Stacked bool `yaml:"stacked,omitempty" json:"stacked,omitempty"` + Normalized bool `yaml:"normalized,omitempty" json:"normalized,omitempty"` + Horizontal bool `yaml:"horizontal,omitempty" json:"horizontal,omitempty"` + Size string `yaml:"size,omitempty" json:"size,omitempty"` + Source string `yaml:"source,omitempty" json:"source,omitempty"` // sankey: source column + Target string `yaml:"target,omitempty" json:"target,omitempty"` // sankey: target column, gauge: target (max) column + Bins int `yaml:"bins,omitempty" json:"bins,omitempty"` // histogram: number of bins + Lines []string `yaml:"lines,omitempty" json:"lines,omitempty"` // combo: which y series render as lines + YMin string `yaml:"yMin,omitempty" json:"yMin,omitempty"` // xmr: min control limit column + YMax string `yaml:"yMax,omitempty" json:"yMax,omitempty"` // xmr: max control limit column + Open string `yaml:"open,omitempty" json:"open,omitempty"` // candlestick: open price column + High string `yaml:"high,omitempty" json:"high,omitempty"` // candlestick: high price column + Low string `yaml:"low,omitempty" json:"low,omitempty"` // candlestick: low price column + Close string `yaml:"close,omitempty" json:"close,omitempty"` // candlestick: close price column // Table fields Columns []TableColumn `yaml:"columns,omitempty" json:"columns,omitempty"` @@ -633,6 +634,11 @@ type ColorEncoding struct { Field string `yaml:"field" json:"field"` } +type SeriesStyle struct { + LineStyle string `yaml:"lineStyle,omitempty" json:"lineStyle,omitempty"` + FillStyle string `yaml:"fillStyle,omitempty" json:"fillStyle,omitempty"` +} + func (c *ColorEncoding) FieldString() string { if c == nil { return "" diff --git a/pkg/dashboard/series_style_test.go b/pkg/dashboard/series_style_test.go new file mode 100644 index 0000000..60b1f96 --- /dev/null +++ b/pkg/dashboard/series_style_test.go @@ -0,0 +1,112 @@ +package dashboard + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLoadFile_SeriesStylesYAML(t *testing.T) { + path := filepath.Join(t.TempDir(), "dashboard.yml") + assertNoErr(t, os.WriteFile(path, []byte(`schema: https://getbruin.com/schemas/dac/dashboard/v1 +name: Pattern Styles +rows: + - widgets: + - name: Revenue vs Forecast + type: chart + chart: line + sql: SELECT month, actual_revenue, forecast_revenue FROM revenue_by_month + x: { field: month } + y: { field: [actual_revenue, forecast_revenue] } + seriesStyles: + actual_revenue: + lineStyle: solid + forecast_revenue: + lineStyle: dashed + fillStyle: hatched +`), 0o644)) + + d, err := LoadFile(path) + assertNoErr(t, err) + + styles := d.Rows[0].Widgets[0].SeriesStyles + if styles["actual_revenue"].LineStyle != "solid" { + t.Fatalf("expected actual_revenue lineStyle solid, got %+v", styles["actual_revenue"]) + } + if styles["forecast_revenue"].LineStyle != "dashed" || styles["forecast_revenue"].FillStyle != "hatched" { + t.Fatalf("expected forecast_revenue dashed + hatched, got %+v", styles["forecast_revenue"]) + } +} + +func TestEvalTSX_SeriesStyles(t *testing.T) { + source := ` +export default ( + + + + + +) +` + d, err := evalTSX(source, "test.tsx", &tsxConfig{}) + assertNoErr(t, err) + + styles := d.Rows[0].Widgets[0].SeriesStyles + if styles["current_share"].FillStyle != "solid" { + t.Fatalf("expected current_share fillStyle solid, got %+v", styles["current_share"]) + } + if styles["prior_share"].FillStyle != "striped" { + t.Fatalf("expected prior_share fillStyle striped, got %+v", styles["prior_share"]) + } +} + +func TestWidgetSeriesStyles_JSONRoundTrip(t *testing.T) { + w := Widget{ + Name: "Revenue", + Type: WidgetTypeChart, + Chart: "line", + SeriesStyles: map[string]SeriesStyle{ + "forecast": {LineStyle: "dotted"}, + }, + } + + data, err := json.Marshal(w) + assertNoErr(t, err) + + var got Widget + assertNoErr(t, json.Unmarshal(data, &got)) + + if got.SeriesStyles["forecast"].LineStyle != "dotted" { + t.Fatalf("expected forecast lineStyle dotted after JSON round trip, got %+v", got.SeriesStyles["forecast"]) + } +} + +func TestValidate_SeriesStylesRejectUnknownValues(t *testing.T) { + d := &Dashboard{ + Name: "Invalid Pattern Styles", + Rows: []Row{{Widgets: []Widget{{ + Name: "Revenue", + Type: WidgetTypeChart, + Chart: "line", + SQL: "SELECT month, forecast FROM revenue_by_month", + X: &AxisEncoding{Field: "month"}, + Y: &AxisEncoding{Field: "forecast"}, + SeriesStyles: map[string]SeriesStyle{ + "forecast": {LineStyle: "dash", FillStyle: "checkerboard"}, + }, + }}}}, + } + + err := Validate(d) + assertErr(t, err) + assertValidationContains(t, err, "lineStyle must be one of solid, dashed, or dotted") + assertValidationContains(t, err, "fillStyle must be one of solid, striped, or hatched") +} diff --git a/pkg/dashboard/validator.go b/pkg/dashboard/validator.go index faeb896..a2fda09 100644 --- a/pkg/dashboard/validator.go +++ b/pkg/dashboard/validator.go @@ -89,6 +89,10 @@ func Validate(d *Dashboard) error { errs = append(errs, fmt.Sprintf("%s: unknown widget type %q (expected metric, chart, table, text, divider, or image)", prefix, w.Type)) } + if len(w.SeriesStyles) > 0 && w.Type != WidgetTypeChart { + errs = append(errs, fmt.Sprintf("%s: seriesStyles is only valid on chart widgets", prefix)) + } + errs = append(errs, validateInlineData(prefix, &w)...) if w.Col < 0 || w.Col > 12 { @@ -267,6 +271,14 @@ var validChartTypes = map[string]bool{ "candlestick": true, } +var validLineStyles = map[string]bool{ + "solid": true, "dashed": true, "dotted": true, +} + +var validFillStyles = map[string]bool{ + "solid": true, "striped": true, "hatched": true, +} + func validateChartWidget(prefix string, w *Widget, d *Dashboard) []string { var errs []string if w.Chart == "" { @@ -278,6 +290,8 @@ func validateChartWidget(prefix string, w *Widget, d *Dashboard) []string { return errs } + errs = append(errs, validateSeriesStyles(prefix, w)...) + // Dimensional chart: uses dimension + metrics from a semantic model // instead of x/y/sql. if w.Dimension != "" || len(w.MetricRefs) > 0 || len(w.Dimensions) > 0 || len(w.Filters) > 0 || len(w.Segments) > 0 || len(w.Sort) > 0 || w.Model != "" { @@ -421,6 +435,29 @@ func validateChartWidget(prefix string, w *Widget, d *Dashboard) []string { return errs } +func validateSeriesStyles(prefix string, w *Widget) []string { + if len(w.SeriesStyles) == 0 { + return nil + } + + var errs []string + for series, style := range w.SeriesStyles { + if series == "" { + errs = append(errs, fmt.Sprintf("%s: seriesStyles keys must not be empty", prefix)) + } + if style.LineStyle == "" && style.FillStyle == "" { + errs = append(errs, fmt.Sprintf("%s: seriesStyles[%q] must set lineStyle or fillStyle", prefix, series)) + } + if style.LineStyle != "" && !validLineStyles[style.LineStyle] { + errs = append(errs, fmt.Sprintf("%s: seriesStyles[%q].lineStyle must be one of solid, dashed, or dotted", prefix, series)) + } + if style.FillStyle != "" && !validFillStyles[style.FillStyle] { + errs = append(errs, fmt.Sprintf("%s: seriesStyles[%q].fillStyle must be one of solid, striped, or hatched", prefix, series)) + } + } + return errs +} + func validateSemanticNamedQuery(d *Dashboard, q Query) error { model, _, err := d.ResolveSemanticModel(q.Model) if err != nil { diff --git a/schemas/dac/dashboard/v1/schema.json b/schemas/dac/dashboard/v1/schema.json index 036a8ab..9e273b5 100644 --- a/schemas/dac/dashboard/v1/schema.json +++ b/schemas/dac/dashboard/v1/schema.json @@ -309,6 +309,9 @@ "color": { "$ref": "#/$defs/colorEncoding" }, + "seriesStyles": { + "$ref": "#/$defs/seriesStyles" + }, "stacked": { "type": "boolean" }, @@ -511,6 +514,29 @@ } }, "additionalProperties": false + }, + "seriesStyles": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/seriesStyle" + } + }, + "seriesStyle": { + "type": "object", + "minProperties": 1, + "properties": { + "lineStyle": { + "enum": ["solid", "dashed", "dotted"] + }, + "fillStyle": { + "enum": ["solid", "striped", "hatched"] + } + }, + "additionalProperties": false } } }