diff --git a/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte b/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte index 9a9642cee51..33ec92be172 100644 --- a/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte +++ b/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte @@ -3,6 +3,7 @@ conditionalFormatSpecToMeasureFormatting, 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"; @@ -43,6 +44,11 @@ $: schema = validateTableSchema($_metricViewSpec, tableSpec); $: widthScopeKey = `canvas:${component.parent.name}:${component.id}`; + let defaultSorting: SortingState; + $: defaultSorting = tableSpec.default_sort + ? [{ id: tableSpec.default_sort.id, desc: tableSpec.default_sort.desc }] + : []; + // Seed the shared pivot state with per-measure formatting from the YAML spec. $: measureFormatting = conditionalFormatSpecToMeasureFormatting( tableSpec.conditional_format, @@ -52,7 +58,7 @@ const columns = tableSpec?.columns || []; pivotState.update((state) => ({ ...state, - sorting: [], + sorting: defaultSorting, expanded: {}, activeCell: null, columnPage: 1, @@ -68,7 +74,7 @@ const rowDimensions = tableSpec.row_dimensions || []; pivotState.update((state) => ({ ...state, - sorting: [], + sorting: defaultSorting, expanded: {}, activeCell: null, columnPage: 1, diff --git a/web-common/src/features/canvas/components/pivot/default-sort.ts b/web-common/src/features/canvas/components/pivot/default-sort.ts new file mode 100644 index 00000000000..8135a1d8caa --- /dev/null +++ b/web-common/src/features/canvas/components/pivot/default-sort.ts @@ -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 { + 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 = {}, +): 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 = {}, +): 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 = { + [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 { + 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; +} diff --git a/web-common/src/features/canvas/components/pivot/index.ts b/web-common/src/features/canvas/components/pivot/index.ts index ed7426b2c7f..04e62ce47c8 100644 --- a/web-common/src/features/canvas/components/pivot/index.ts +++ b/web-common/src/features/canvas/components/pivot/index.ts @@ -95,6 +95,18 @@ export function measureFormattingToConditionalFormatSpec( ); } +/** + * 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 { @@ -104,6 +116,7 @@ export interface PivotSpec col_dimensions?: string[]; hide_totals_row?: boolean; hide_totals_col?: boolean; + default_sort?: DefaultSort; conditional_format?: PivotConditionalFormatSpec[]; row_limit?: string; } @@ -115,6 +128,7 @@ export interface TableSpec columns: string[]; hide_totals_row?: boolean; hide_totals_col?: boolean; + default_sort?: DefaultSort; conditional_format?: PivotConditionalFormatSpec[]; } @@ -287,6 +301,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent< meta: { defaultValue: false }, showInUI: canShowTotalRow, }, + default_sort: { type: "default_sort", label: "Default sort" }, row_limit: { type: "select", label: "Row limit", @@ -334,6 +349,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent< meta: { defaultValue: false }, showInUI: canShowTotalRow, }, + default_sort: { type: "default_sort", label: "Default sort" }, ...getCommonOptions(), }, filter: getFilterOptions(true, false), @@ -397,7 +413,10 @@ export class PivotCanvasComponent extends BaseCanvasComponent< ComponentFilterProperties & Pick< PivotSpec, - "hide_totals_row" | "hide_totals_col" | "conditional_format" + | "hide_totals_row" + | "hide_totals_col" + | "conditional_format" + | "default_sort" > = { title: currentSpec.title, description: currentSpec.description, @@ -405,6 +424,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent< time_filters: currentSpec.time_filters, hide_totals_row: currentSpec.hide_totals_row, hide_totals_col: currentSpec.hide_totals_col, + default_sort: undefined, conditional_format: currentSpec.conditional_format, }; diff --git a/web-common/src/features/canvas/components/pivot/selector.ts b/web-common/src/features/canvas/components/pivot/selector.ts index 2cc7d9c06e4..bf8ece74664 100644 --- a/web-common/src/features/canvas/components/pivot/selector.ts +++ b/web-common/src/features/canvas/components/pivot/selector.ts @@ -3,8 +3,8 @@ import { validateMeasures, } from "@rilldata/web-common/features/canvas/components/validators"; import { isTimeDimension } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; -import type { PivotSpec, TableSpec } from "./"; import type { V1MetricsViewSpec } from "@rilldata/web-common/runtime-client"; +import type { PivotSpec, TableSpec } from "./"; export function validateTableSchema( metricsViewQuery: { diff --git a/web-common/src/features/canvas/inspector/DefaultSortInput.svelte b/web-common/src/features/canvas/inspector/DefaultSortInput.svelte new file mode 100644 index 00000000000..ddfa696e84e --- /dev/null +++ b/web-common/src/features/canvas/inspector/DefaultSortInput.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#if shownSort} + {#if chipsReady} +
+ {#each chips as chip, i (i)} + {#if i > 0} + + + + {/if} + + {chip.label} + + {/each} + + + +
+ {:else} +
+ +
+ {/if} + +
+ + {#if storedDefault} + + {/if} +
+ {:else} + + Sort a column on the table to set it as the default. + + {/if} +
diff --git a/web-common/src/features/canvas/inspector/ParamMapper.svelte b/web-common/src/features/canvas/inspector/ParamMapper.svelte index 0eddaf54db7..158703342b3 100644 --- a/web-common/src/features/canvas/inspector/ParamMapper.svelte +++ b/web-common/src/features/canvas/inspector/ParamMapper.svelte @@ -17,6 +17,7 @@ import MetricsSQLInput from "./chart/MetricsSQLInput.svelte"; import PositionalFieldConfig from "./chart/PositionalFieldConfig.svelte"; import ComparisonInput from "./ComparisonInput.svelte"; + import DefaultSortInput from "./DefaultSortInput.svelte"; import MultiFieldFormatInput from "./fields/MultiFieldFormatInput.svelte"; import MultiFieldInput from "./fields/MultiFieldInput.svelte"; import SingleFieldInput from "./fields/SingleFieldInput.svelte"; @@ -248,6 +249,10 @@ }} /> + + {:else if config.type === "default_sort" && component instanceof PivotCanvasComponent} + + {:else if config.type === "switcher_tab"}