Skip to content
Merged
1,414 changes: 714 additions & 700 deletions proto/gen/rill/runtime/v1/resources.pb.go

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions proto/gen/rill/runtime/v1/resources.pb.validate.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions proto/gen/rill/runtime/v1/runtime.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5371,6 +5371,11 @@ definitions:
type: boolean
pivotShowTotalsRow:
type: boolean
pivotFormatting:
type: string
description: |-
Per-measure pivot conditional formatting, serialized in the URL param
format (frontend-only; persisted in URL state).
chartDynamicYAxis:
type: boolean
title: Chart display settings (frontend-only; persisted in URL state)
Expand Down
3 changes: 3 additions & 0 deletions proto/rill/runtime/v1/resources.proto
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,9 @@ message ExplorePreset {
optional int32 pivot_row_limit = 33;
optional bool pivot_show_totals_column = 37;
optional bool pivot_show_totals_row = 38;
// Per-measure pivot conditional formatting, serialized in the URL param
// format (frontend-only; persisted in URL state).
optional string pivot_formatting = 39;

// Chart display settings (frontend-only; persisted in URL state)
optional bool chart_dynamic_y_axis = 35;
Expand Down
26 changes: 26 additions & 0 deletions proto/rill/ui/v1/dashboard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ message DashboardState {
repeated PivotElement pivot_row_all_dimensions = 35;
// List of all dimensions selected for columns
repeated PivotElement pivot_column_all_dimensions = 36;

// Per-measure conditional formatting (heatmap / data bar) for pivot cells.
repeated PivotConditionalFormat pivot_conditional_formatting = 44;
}

message DashboardTimeRange {
Expand All @@ -180,3 +183,26 @@ message PivotElement {
string pivot_dimension = 2;
}
}

// Conditional formatting applied to a measure's cells in a pivot table.
message PivotConditionalFormat {
string measure = 1;
// Formatting style: "heatmap", "data_bar" or "rules".
string mode = 2;
// Color scheme key (e.g. "theme-sequential", "greens", "redYellowGreen").
// Only for "heatmap" and "data_bar" modes.
string scheme = 3;
// Ordered threshold rules; first match wins. Only for "rules" mode.
repeated PivotFormatRule rules = 5;
}

// A single threshold rule for "rules" mode conditional formatting.
message PivotFormatRule {
// Comparison operator: "gt", "gte", "lt", "lte", "eq" or "between".
string operator = 1;
double value = 2;
// Upper bound (inclusive), only for "between".
optional double value2 = 3;
// Semantic color key (e.g. "positive") or hex string.
string color = 4;
}
7 changes: 7 additions & 0 deletions web-common/src/components/table/tanstack-table-column-meta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RowData } from "tanstack-table-8-svelte-5";
import type { ComponentType, SvelteComponent } from "svelte";
import type { PivotMeasureFormatting } from "@rilldata/web-common/features/dashboards/pivot/types";

declare module "tanstack-table-8-svelte-5" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -12,5 +13,11 @@ declare module "tanstack-table-8-svelte-5" {
/** Description shown in the header name tooltip */
description?: string;
tooltipFormatter?: (value: unknown) => string | null | undefined;
/** Conditional formatting config for a main measure column (heatmap/data bar) */
conditionalFormat?: PivotMeasureFormatting;
/** Base measure name, used to look up the per-measure color domain */
measureName?: string;
/** True for the row-totals column leaves, which are excluded from formatting */
isRowTotal?: boolean;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import type { PivotCanvasComponent } from "@rilldata/web-common/features/canvas/components/pivot";
import {
conditionalFormatSpecToMeasureFormatting,
type PivotCanvasComponent,
} from "@rilldata/web-common/features/canvas/components/pivot";
import ComponentHeader from "../../ComponentHeader.svelte";
import CanvasPivotRenderer from "./CanvasPivotRenderer.svelte";
import { validateTableSchema } from "./selector";
Expand Down Expand Up @@ -40,6 +43,11 @@
$: schema = validateTableSchema($_metricViewSpec, tableSpec);
$: widthScopeKey = `canvas:${component.parent.name}:${component.id}`;

// Seed the shared pivot state with per-measure formatting from the YAML spec.
$: measureFormatting = conditionalFormatSpecToMeasureFormatting(
tableSpec.conditional_format,
);

$: if ("columns" in tableSpec && schema.isValid && !schema.isLoading) {
const columns = tableSpec?.columns || [];
pivotState.update((state) => ({
Expand All @@ -52,6 +60,7 @@
columns: tableFieldMapper(columns, metricsViewSpec),
showTotalsColumn: tableSpec.hide_totals_col !== true,
showTotalsRow: tableSpec.hide_totals_row !== true,
measureFormatting,
}));
} else if (!("columns" in tableSpec) && schema.isValid && !schema.isLoading) {
const measures = tableSpec.measures || [];
Expand All @@ -71,6 +80,7 @@
rows: tableFieldMapper(rowDimensions, metricsViewSpec),
showTotalsColumn: tableSpec.hide_totals_col !== true,
showTotalsRow: tableSpec.hide_totals_row !== true,
measureFormatting,
rowLimit: normalizeRowLimit(tableSpec.row_limit),
outermostRowLimit: undefined,
}));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
conditionalFormatSpecToMeasureFormatting,
measureFormattingToConditionalFormatSpec,
type PivotConditionalFormatSpec,
} from "./index";

describe("conditionalFormatSpecToMeasureFormatting", () => {
it("maps heatmap, data bar and rule specs per measure", () => {
const specs: PivotConditionalFormatSpec[] = [
{ measure: "revenue", mode: "heatmap", scheme: "red-green" },
{ measure: "cost", mode: "data_bar" },
{
measure: "margin",
mode: "rules",
rules: [{ operator: "gt", value: 0, color: "green" }],
},
];
expect(conditionalFormatSpecToMeasureFormatting(specs)).toEqual({
revenue: { mode: "heatmap", scheme: "red-green" },
cost: { mode: "data_bar", scheme: "theme-sequential" },
margin: {
mode: "rules",
rules: [{ operator: "gt", value: 0, color: "green" }],
},
});
});

// The canvas YAML is hand-editable, so malformed values must be skipped
// instead of crashing the canvas.
it("returns empty formatting when the field is not an array", () => {
for (const bad of [undefined, null, 5, "heatmap", { measure: "revenue" }]) {
expect(conditionalFormatSpecToMeasureFormatting(bad as never)).toEqual(
{},
);
}
});

it("skips entries that are not objects or lack a measure name", () => {
const specs = [
null,
"revenue",
{ mode: "heatmap" },
{ measure: "revenue", mode: "heatmap" },
];
expect(conditionalFormatSpecToMeasureFormatting(specs as never)).toEqual({
revenue: { mode: "heatmap", scheme: "theme-sequential" },
});
});

it("skips rules specs whose rules are not a non-empty array of objects", () => {
const specs = [
{ measure: "a", mode: "rules", rules: "gt 5" },
{ measure: "b", mode: "rules", rules: [] },
{ measure: "c", mode: "rules", rules: [null, "x"] },
{
measure: "d",
mode: "rules",
rules: [null, { operator: "gt", value: 0, color: "green" }],
},
];
expect(conditionalFormatSpecToMeasureFormatting(specs as never)).toEqual({
d: {
mode: "rules",
rules: [{ operator: "gt", value: 0, color: "green" }],
},
});
});

it("falls back to the default scheme for non-string schemes", () => {
const specs = [{ measure: "a", mode: "heatmap", scheme: 3 }];
expect(conditionalFormatSpecToMeasureFormatting(specs as never)).toEqual({
a: { mode: "heatmap", scheme: "theme-sequential" },
});
});

it("round-trips through measureFormattingToConditionalFormatSpec", () => {
const specs: PivotConditionalFormatSpec[] = [
{ measure: "revenue", mode: "heatmap", scheme: "red-green" },
{
measure: "margin",
mode: "rules",
rules: [{ operator: "between", value: 0, value2: 10, color: "blue" }],
},
];
expect(
measureFormattingToConditionalFormatSpec(
conditionalFormatSpecToMeasureFormatting(specs),
),
).toEqual(specs);
});
});
103 changes: 99 additions & 4 deletions web-common/src/features/canvas/components/pivot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { InputParams } from "@rilldata/web-common/features/canvas/inspector
import { PIVOT_ROW_LIMIT_OPTIONS } from "@rilldata/web-common/features/dashboards/pivot/pivot-constants";
import type {
PivotDataStoreConfig,
PivotFormatRule,
PivotMeasureFormatting,
PivotState,
} from "@rilldata/web-common/features/dashboards/pivot/types";
import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state";
Expand All @@ -30,6 +32,69 @@ import {
usePivotForCanvas,
} from "./util";

// Per-measure conditional formatting persisted in the canvas YAML. A list (not
// a map) keeps the YAML readable and mirrors the proto representation.
export interface PivotConditionalFormatSpec {
measure: string;
mode: "heatmap" | "data_bar" | "rules";
// Color scheme; only for "heatmap" and "data_bar" modes.
scheme?: string;
// Ordered threshold rules (first match wins); only for "rules" mode.
rules?: {
operator: PivotFormatRule["operator"];
value: number;
value2?: number;
color: string;
}[];
}

const DEFAULT_FORMAT_SCHEME = "theme-sequential";

/**
* Map the YAML conditional_format list to the per-measure formatting config
* consumed by the pivot renderer. The YAML is hand-editable and reaches here
* unvalidated, so malformed values and entries are skipped rather than trusted
* to match the declared type.
*/
export function conditionalFormatSpecToMeasureFormatting(
specs: PivotConditionalFormatSpec[] | undefined,
): Record<string, PivotMeasureFormatting> {
const measureFormatting: Record<string, PivotMeasureFormatting> = {};
if (!Array.isArray(specs)) return measureFormatting;
for (const spec of specs) {
if (!spec || typeof spec !== "object" || typeof spec.measure !== "string") {
continue;
}
if (spec.mode === "heatmap" || spec.mode === "data_bar") {
measureFormatting[spec.measure] = {
mode: spec.mode,
scheme:
typeof spec.scheme === "string" ? spec.scheme : DEFAULT_FORMAT_SCHEME,
};
} else if (spec.mode === "rules" && Array.isArray(spec.rules)) {
const rules = spec.rules.filter((r) => r && typeof r === "object");
if (rules.length) {
measureFormatting[spec.measure] = { mode: "rules", rules };
}
}
}
return measureFormatting;
}

/**
* Inverse of conditionalFormatSpecToMeasureFormatting, used when writing the
* inspector state back to the YAML.
*/
export function measureFormattingToConditionalFormatSpec(
measureFormatting: Record<string, PivotMeasureFormatting>,
): PivotConditionalFormatSpec[] {
return Object.entries(measureFormatting).map(([measure, fmt]) =>
fmt.mode === "rules"
? { measure, mode: fmt.mode, rules: fmt.rules }
: { measure, mode: fmt.mode, scheme: fmt.scheme },
);
}

export interface PivotSpec
extends ComponentCommonProperties,
ComponentFilterProperties {
Expand All @@ -39,6 +104,7 @@ export interface PivotSpec
col_dimensions?: string[];
hide_totals_row?: boolean;
hide_totals_col?: boolean;
conditional_format?: PivotConditionalFormatSpec[];
Comment thread
nishantmonu51 marked this conversation as resolved.
row_limit?: string;
}

Expand All @@ -49,6 +115,7 @@ export interface TableSpec
columns: string[];
hide_totals_row?: boolean;
hide_totals_col?: boolean;
conditional_format?: PivotConditionalFormatSpec[];
}

export { default as Pivot } from "./CanvasPivotDisplay.svelte";
Expand All @@ -60,7 +127,12 @@ export class PivotCanvasComponent extends BaseCanvasComponent<
> {
minSize = { width: 2, height: 2 };
defaultSize = { width: 4, height: 10 };
resetParams = ["measures", "row_dimensions", "col_dimensions"];
resetParams = [
"measures",
"row_dimensions",
"col_dimensions",
"conditional_format",
];
type: CanvasComponentType;
component = CanvasPivotDisplay;
config: Readable<PivotDataStoreConfig>;
Expand Down Expand Up @@ -148,6 +220,25 @@ export class PivotCanvasComponent extends BaseCanvasComponent<
activePage: DashboardState_ActivePage.PIVOT,
};
}

/** Update a single measure's conditional formatting in the YAML; pass null to clear it. */
setMeasureFormatting(
measureName: string,
fmt: PivotMeasureFormatting | null,
) {
const measureFormatting = conditionalFormatSpecToMeasureFormatting(
get(this.specStore).conditional_format,
);
if (fmt) {
measureFormatting[measureName] = fmt;
} else {
delete measureFormatting[measureName];
}
this.updateProperty(
"conditional_format",
measureFormattingToConditionalFormatSpec(measureFormatting),
);
}
inputParams(type: "pivot" | "table"): InputParams<PivotSpec | TableSpec> {
const spec = get(this.specStore);

Expand All @@ -170,7 +261,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent<
label: m.canvas_metrics_view_label(),
},
measures: {
type: "multi_fields",
type: "multi_fields_format",
meta: { allowedTypes: ["measure"] },
label: m.canvas_measures_label(),
},
Expand Down Expand Up @@ -233,7 +324,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent<
label: m.canvas_metrics_view_label(),
},
columns: {
type: "multi_fields",
type: "multi_fields_format",
label: m.canvas_columns_label(),
meta: { allowedTypes: ["time", "dimension", "measure"] },
},
Expand Down Expand Up @@ -304,13 +395,17 @@ export class PivotCanvasComponent extends BaseCanvasComponent<

const commonProperties: ComponentCommonProperties &
ComponentFilterProperties &
Pick<PivotSpec, "hide_totals_row" | "hide_totals_col"> = {
Pick<
PivotSpec,
"hide_totals_row" | "hide_totals_col" | "conditional_format"
> = {
title: currentSpec.title,
description: currentSpec.description,
dimension_filters: currentSpec.dimension_filters,
time_filters: currentSpec.time_filters,
hide_totals_row: currentSpec.hide_totals_row,
hide_totals_col: currentSpec.hide_totals_col,
conditional_format: currentSpec.conditional_format,
};

if ("columns" in currentSpec) {
Expand Down
Loading
Loading