Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PivotCanvasComponent } from "@rilldata/web-common/features/canvas/components/pivot";
import type { SortingState } from "tanstack-table-8-svelte-5";
import ComponentHeader from "../../ComponentHeader.svelte";
import CanvasPivotRenderer from "./CanvasPivotRenderer.svelte";
import { validateTableSchema } from "./selector";
Expand Down Expand Up @@ -37,14 +38,23 @@
$: _metricViewSpec = getMetricsViewFromName(tableSpec.metrics_view);
$: metricsViewSpec = $_metricViewSpec.metricsView;

$: schema = validateTableSchema(metricsViewSpec, tableSpec);
$: schema = validateTableSchema(
metricsViewSpec,
tableSpec,
$_metricViewSpec.isLoading,
);
$: widthScopeKey = `canvas:${component.parent.name}:${component.id}`;

let defaultSorting: SortingState;
$: defaultSorting = tableSpec.default_sort
? [{ id: tableSpec.default_sort.id, desc: tableSpec.default_sort.desc }]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate default_sort before reapplying it

When a saved default sort points at a field that is no longer part of the current table (for example, set the default on sales, then remove sales from measures/columns or change the metrics view), this always rehydrates that stale id into pivotState.sorting. For nested pivots, getSortForAccessor only treats ids in the current measureNames or row dimension as stable; a stale stable id falls through to nested-accessor parsing and can index columnDimensionAxes[undefined][NaN], crashing the table, while flat tables send a sort for a non-selected field. Please clear or validate default_sort against the current fields before applying it.

Useful? React with 👍 / 👎.

: [];

$: if ("columns" in tableSpec && schema.isValid) {
const columns = tableSpec?.columns || [];
pivotState.update((state) => ({
...state,
sorting: [],
sorting: defaultSorting,
expanded: {},
activeCell: null,
columnPage: 1,
Expand All @@ -59,7 +69,7 @@
const rowDimensions = tableSpec.row_dimensions || [];
pivotState.update((state) => ({
...state,
sorting: [],
sorting: defaultSorting,
expanded: {},
activeCell: null,
columnPage: 1,
Expand Down
208 changes: 208 additions & 0 deletions web-common/src/features/canvas/components/pivot/default-sort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {
extractNumbers,
getTimeGrainFromDimension,
isTimeDimension,
} from "@rilldata/web-common/features/dashboards/pivot/pivot-utils";
import {
COMPARISON_DELTA,
COMPARISON_PERCENT,
COMPARISON_VALUE,
type PivotDataStoreConfig,
} from "@rilldata/web-common/features/dashboards/pivot/types";
import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config";
import { convertISOStringToJSDateWithSameTimeAsSelectedTimeZone } from "@rilldata/web-common/lib/time/timezone";
import { timeFormat } from "d3-time-format";
import type { DefaultSort } from "./index";

/** Separator between column-dimension values in a nested-sort label. */
const LABEL_SEPARATOR = " › ";

/** Matches a nested pivot leaf accessor, e.g. "c0v2_c1v0m0" or "c0v2m0". */
const NESTED_ACCESSOR_RE = /^(c\d+v\d+_?)+m\d+$/;

/**
* Builds the human-readable label for a sort id. For stable ids (measure,
* dimension, time grain) this resolves the display name from the metrics view.
* For nested pivot leaf accessors it decodes the column-dimension value path
* and joins it with the measure display name, e.g. "US › 2024 › Sales".
*
* Returns the raw id as a fallback when nothing better can be resolved.
*/
export function getSortLabel(
id: string,
config: PivotDataStoreConfig,
columnDimensionAxes: Record<string, string[]> = {},
): string {
if (NESTED_ACCESSOR_RE.test(id)) {
return getNestedAccessorLabel(id, config, columnDimensionAxes);
}
return getFieldLabel(id, config);
}

export type SortFieldType = "measure" | "dimension" | "time";

export interface SortChip {
label: string;
type: SortFieldType;
}

/**
* Breaks a sort id into the chips shown in the inspector. A stable field is a
* single chip; a nested pivot leaf accessor becomes one chip per column-
* dimension value followed by the measure chip, e.g. [US, 2024, Sales].
*/
export function getSortChips(
id: string,
config: PivotDataStoreConfig,
columnDimensionAxes: Record<string, string[]> = {},
): SortChip[] {
if (NESTED_ACCESSOR_RE.test(id)) {
const [colPart, measureIndexPart] = id.split("m");
const parts = colPart.split("_").filter(Boolean);

const chips: SortChip[] = parts.map((part) => {
const { c, v } = extractNumbers(part);
const dimensionName = config.colDimensionNames[c];
const value = columnDimensionAxes[dimensionName]?.[v] ?? "";
const isTime = isTimeDimension(dimensionName, config.time.timeDimension);
return {
label: isTime
? formatColumnTimeValue(value, dimensionName, config)
: value,
type: isTime ? "time" : "dimension",
};
});

const measureName = config.measureNames[parseInt(measureIndexPart)];
if (measureName) {
chips.push({
label: getFieldLabel(measureName, config),
type: "measure",
});
}
return chips;
}

return [
{ label: getFieldLabel(id, config), type: getSortFieldType(id, config) },
];
}

/**
* Whether all chip values for a sort id can be resolved from the currently
* loaded data. Nested pivot leaf accessors reference column-dimension values
* that arrive asynchronously; until they load, chips would render blank or,
* for time dimensions, as "NaN". Stable fields resolve synchronously.
*/
export function areSortChipsReady(
id: string,
config: PivotDataStoreConfig,
columnDimensionAxes: Record<string, string[]> = {},
): boolean {
if (!NESTED_ACCESSOR_RE.test(id)) return true;

const [colPart] = id.split("m");
const parts = colPart.split("_").filter(Boolean);

return parts.every((part) => {
const { c, v } = extractNumbers(part);
const dimensionName = config.colDimensionNames[c];
return columnDimensionAxes[dimensionName]?.[v] != null;
});
}

/**
* Resolves the field type of a sort id, used to color the inspector chip.
* A nested pivot leaf accessor sorts by its measure, so it reads as "measure".
*/
export function getSortFieldType(
id: string,
config: PivotDataStoreConfig,
): SortFieldType {
if (NESTED_ACCESSOR_RE.test(id)) return "measure";
if (isTimeDimension(id, config.time.timeDimension)) return "time";
if (config.allMeasures.some((m) => m.name === id)) return "measure";
return "dimension";
}

/** Human-readable suffix appended to a base measure for comparison columns. */
const COMPARISON_MODIFIER: Record<string, string> = {
[COMPARISON_VALUE]: " (previous)",
[COMPARISON_DELTA]: " Δ",
[COMPARISON_PERCENT]: " Δ %",
};

function formatColumnTimeValue(
value: string,
dimensionName: string,
config: PivotDataStoreConfig,
): string {
const grain = getTimeGrainFromDimension(dimensionName);
const dt = convertISOStringToJSDateWithSameTimeAsSelectedTimeZone(
value,
config.time.timeZone || "UTC",
);
const formatter = timeFormat(grain ? TIME_GRAIN[grain].d3format : "%H:%M");
return formatter(dt);
}

function getFieldLabel(field: string, config: PivotDataStoreConfig): string {
if (isTimeDimension(field, config.time.timeDimension)) {
const grain = getTimeGrainFromDimension(field);
return `Time ${TIME_GRAIN[grain]?.label ?? grain}`;
}

// Comparison columns are the base measure plus a suffix (e.g. "sales__delta_rel").
for (const [suffix, modifier] of Object.entries(COMPARISON_MODIFIER)) {
if (field.endsWith(suffix)) {
const baseName = field.slice(0, -suffix.length);
return getFieldLabel(baseName, config) + modifier;
}
}

const measure = config.allMeasures.find((m) => m.name === field);
if (measure) return measure.displayName || (measure.name as string);

const dimension = config.allDimensions.find(
(d) => (d.name || d.column) === field,
);
if (dimension) {
return (
dimension.displayName || dimension.name || (dimension.column as string)
);
}

return field;
}

function getNestedAccessorLabel(
accessor: string,
config: PivotDataStoreConfig,
columnDimensionAxes: Record<string, string[]>,
): string {
const [colPart, measureIndexPart] = accessor.split("m");
const parts = colPart.split("_").filter(Boolean);

const valueLabels = parts.map((part) => {
const { c, v } = extractNumbers(part);
const dimensionName = config.colDimensionNames[c];
return columnDimensionAxes[dimensionName]?.[v] ?? "";
});

const measureName = config.measureNames[parseInt(measureIndexPart)];
const measureLabel = measureName ? getFieldLabel(measureName, config) : "";

return [...valueLabels, measureLabel].filter(Boolean).join(LABEL_SEPARATOR);
}

/**
* Whether two sort configs target the same column and direction. Used to
* disable the "Make default" button when the active sort already is the default.
*/
export function sortEquals(
a: DefaultSort | undefined,
b: { id: string; desc: boolean } | undefined,
): boolean {
if (!a || !b) return false;
return a.id === b.id && a.desc === b.desc;
}
42 changes: 34 additions & 8 deletions web-common/src/features/canvas/components/pivot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ import type {
import CanvasPivotDisplay from "./CanvasPivotDisplay.svelte";
import { createPivotConfig, usePivotForCanvas } from "./util";

/**
* Default sort applied when the table first loads. Mirrors how explore
* persists pivot sort: `id` is the raw TanStack sort id (measure, dimension,
* time grain, or nested leaf accessor), decoded at query time. `label` is
* stored purely for human-readable display in the inspector.
*/
export interface DefaultSort {
id: string;
desc: boolean;
label: string;
}

export interface PivotSpec
extends ComponentCommonProperties,
ComponentFilterProperties {
Expand All @@ -34,6 +46,7 @@ export interface PivotSpec
col_dimensions?: string[];
hide_totals_row?: boolean;
hide_totals_col?: boolean;
default_sort?: DefaultSort;
}

export interface TableSpec
Expand All @@ -43,6 +56,7 @@ export interface TableSpec
columns: string[];
hide_totals_row?: boolean;
hide_totals_col?: boolean;
default_sort?: DefaultSort;
}

export { default as Pivot } from "./CanvasPivotDisplay.svelte";
Expand Down Expand Up @@ -186,6 +200,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent<
meta: { defaultValue: false },
showInUI: canShowTotalRow,
},
default_sort: { type: "default_sort", label: "Default sort" },
...commonOptions,
},
filter: getFilterOptions(true, false),
Expand Down Expand Up @@ -216,6 +231,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent<
meta: { defaultValue: false },
showInUI: canShowTotalRow,
},
default_sort: { type: "default_sort", label: "Default sort" },
...commonOptions,
},
filter: getFilterOptions(true, false),
Expand Down Expand Up @@ -275,16 +291,26 @@ export class PivotCanvasComponent extends BaseCanvasComponent<

let newSpec: PivotSpec | TableSpec;

// A nested leaf accessor (e.g. "c0v2m0") is meaningless in the other table
// mode, so drop the default sort unless it targets a stable field.
const defaultSort =
currentSpec.default_sort &&
/^(c\d+v\d+_?)+m\d+$/.test(currentSpec.default_sort.id)
? undefined
: currentSpec.default_sort;

const commonProperties: ComponentCommonProperties &
ComponentFilterProperties &
Pick<PivotSpec, "hide_totals_row" | "hide_totals_col"> = {
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,
};
Pick<PivotSpec, "hide_totals_row" | "hide_totals_col" | "default_sort"> =
{
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,
default_sort: defaultSort,
};

if ("columns" in currentSpec) {
const row_dimensions =
Expand Down
7 changes: 7 additions & 0 deletions web-common/src/features/canvas/components/pivot/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ import type { V1MetricsViewSpec } from "@rilldata/web-common/runtime-client";
export function validateTableSchema(
metricsView: V1MetricsViewSpec | undefined,
tableSpec: PivotSpec | TableSpec,
isLoading = false,
): {
isValid: boolean;
error?: string;
} {
// While the metrics view resource is still loading its spec is undefined;
// treat this as valid so we don't flash a "not found" error on page load.
if (isLoading) {
return { isValid: true, error: undefined };
}

if (!metricsView) {
return {
isValid: false,
Expand Down
Loading
Loading