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
96 changes: 92 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,62 @@ 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. Malformed entries are skipped.
*/
export function conditionalFormatSpecToMeasureFormatting(
specs: PivotConditionalFormatSpec[] | undefined,
): Record<string, PivotMeasureFormatting> {
const measureFormatting: Record<string, PivotMeasureFormatting> = {};
for (const spec of specs ?? []) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard untyped conditional_format before iterating

When a canvas YAML is hand-edited or loaded with conditional_format set to a non-array value (or with a null entry), this loop throws before rendering because renderer props are untyped and the runtime canvas validators do not validate this new field. That means one malformed formatting block can crash the canvas/inspector instead of being skipped as the comment promises. Please validate the field or guard with Array.isArray and object checks before iterating.

Useful? React with 👍 / 👎.

if (spec.mode === "heatmap" || spec.mode === "data_bar") {
measureFormatting[spec.measure] = {
mode: spec.mode,
scheme: spec.scheme ?? DEFAULT_FORMAT_SCHEME,
};
} else if (spec.mode === "rules" && spec.rules?.length) {
measureFormatting[spec.measure] = {
mode: "rules",
rules: spec.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 +97,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 +108,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 +120,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 +213,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 +254,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 +317,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 +388,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