From 3e0f293b81a3b12f2b27dce0c43b4fd542aff3d6 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Sat, 5 Sep 2026 16:04:24 +0530 Subject: [PATCH 1/5] feat: ephemeral measures UI for explore and canvas dashboards Frontend for ephemeral (calculated) measures on top of the `expression` measure compute: users define ad-hoc measures such as `profit = revenue - cost` from the explore measure selector, the pivot sidebar and the canvas inspector. Definitions live on the explore state (encoded in the `ephemeral` URL param and explore preset) or on a canvas component's `ephemeral_measures` prop, and every view attaches the expression compute to matching request measures. Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf --- proto/rill/ui/v1/dashboard.proto | 16 + .../src/features/public-urls/form-utils.ts | 24 +- .../menu/DashboardMetricsDraggableList.svelte | 79 +++ .../SearchableFilterChip.svelte | 18 +- .../SearchableFilterSelectableItem.ts | 4 + .../SearchableMenuContent.svelte | 144 ++-- .../src/features/alerts/create-alert-utils.ts | 25 +- .../canvas/components/charts/BaseChart.ts | 8 + .../canvas/components/kpi-grid/KPIGrid.svelte | 1 + .../canvas/components/kpi-grid/index.ts | 12 +- .../canvas/components/kpi/KPIProvider.svelte | 27 +- .../features/canvas/components/kpi/index.ts | 4 + .../canvas/components/kpi/selector.ts | 6 +- .../leaderboard/LeaderboardDisplay.svelte | 13 +- .../canvas/components/leaderboard/index.ts | 12 +- .../canvas/components/leaderboard/selector.ts | 14 +- .../pivot/CanvasPivotDisplay.svelte | 12 +- .../features/canvas/components/pivot/index.ts | 20 + .../canvas/components/pivot/selector.ts | 23 +- .../features/canvas/components/pivot/util.ts | 49 +- .../features/canvas/components/validators.ts | 8 +- .../canvas/inspector/ParamMapper.svelte | 18 + .../chart/PositionalFieldConfig.svelte | 10 + .../fields/EphemeralMeasureEditor.svelte | 281 ++++++++ .../fields/FieldSelectorDropdown.svelte | 98 ++- .../fields/MultiFieldFormatInput.svelte | 4 + .../inspector/fields/MultiFieldInput.svelte | 47 +- .../fields/MultiPositionalFieldsInput.svelte | 47 +- .../inspector/fields/SingleFieldInput.svelte | 80 ++- .../features/canvas/inspector/selectors.ts | 12 +- .../src/features/canvas/inspector/types.ts | 1 + .../cartesian/CartesianChartProvider.ts | 30 +- .../charts/circular/CircularChartProvider.ts | 9 +- .../charts/combo/ComboChartProvider.ts | 10 +- .../components/charts/data-provider.ts | 13 +- .../components/charts/ephemeral-measures.ts | 56 ++ .../charts/funnel/FunnelChartProvider.ts | 6 + .../charts/heatmap/HeatmapChartProvider.ts | 7 +- .../scatter/ScatterPlotChartProvider.ts | 8 +- .../src/features/components/charts/types.ts | 4 + .../dashboards/aggregation-request-utils.ts | 28 +- .../big-number/MeasureBigNumber.svelte | 27 +- .../big-number/MeasuresContainer.svelte | 19 +- .../dimension-table/DimensionDisplay.svelte | 50 +- .../dimension-table/dimension-table-export.ts | 15 +- .../dimension-table/dimension-table-utils.ts | 9 +- .../CreateEphemeralMeasureButton.svelte | 31 + .../EphemeralMeasureDialog.svelte | 229 +++++++ .../dashboards/ephemeral-measures/canvas.ts | 34 + .../ephemeral-measures/dialog-store.ts | 8 + .../ephemeral-measures.spec.ts | 124 ++++ .../expression-parser.spec.ts | 140 ++++ .../ephemeral-measures/expression-parser.ts | 613 ++++++++++++++++++ .../ephemeral-measures/measure-mapping.ts | 116 ++++ .../dashboards/ephemeral-measures/types.ts | 18 + .../ephemeral-measures/url-param.spec.ts | 218 +++++++ .../ephemeral-measures/url-param.ts | 78 +++ .../ephemeral-measures/url-state.ts | 80 +++ .../ephemeral-measures/validation.ts | 99 +++ .../dashboards/leaderboard/Leaderboard.svelte | 73 ++- .../leaderboard/LeaderboardDisplay.svelte | 1 + .../features/dashboards/pivot/AddField.svelte | 74 ++- .../features/dashboards/pivot/DragList.svelte | 16 + .../dashboards/pivot/MeasureFormatChip.svelte | 43 ++ .../dashboards/pivot/PivotDisplay.svelte | 10 + .../dashboards/pivot/PivotDrag.svelte | 18 +- .../dashboards/pivot/PivotHeader.svelte | 12 + .../dashboards/pivot/PivotSidebar.svelte | 15 +- .../dashboards/pivot/pivot-data-config.ts | 25 +- .../features/dashboards/pivot/pivot-export.ts | 15 +- .../dashboards/pivot/pivot-queries.ts | 7 +- .../features/dashboards/pivot/pivot-utils.ts | 22 +- .../src/features/dashboards/pivot/types.ts | 5 + .../dashboards/proto-state/fromProto.ts | 19 +- .../dashboards/proto-state/toProto.ts | 8 + .../loaders/most-recent-explore-state.ts | 1 + .../state-managers/selectors/measures.ts | 81 ++- .../state-managers/selectors/pivot.ts | 14 +- .../stores/AdvancedMeasureCorrector.ts | 13 +- .../dashboards/stores/dashboard-stores.ts | 139 +++- .../dashboards/stores/explore-state.ts | 10 + .../validate-and-clean-explore-state.ts | 19 + .../time-dimension-details/TDDHeader.svelte | 20 +- .../time-dimension-details/tdd-export.ts | 9 +- .../time-dimension-data-store.ts | 12 +- .../MetricsTimeSeriesCharts.svelte | 23 +- .../time-series/ScreenshotContainer.svelte | 4 + .../measure-chart/MeasureChart.svelte | 13 +- .../measure-chart/use-dimension-data.ts | 8 +- .../time-series/multiple-dimension-queries.ts | 14 +- .../time-series/timeseries-data-store.ts | 17 +- .../time-series/totals-data-store.ts | 11 +- ...ert-partial-explore-state-to-url-params.ts | 10 + .../convertLegacyStateToExplorePreset.ts | 23 + .../url-state/convertPresetToExploreState.ts | 15 + .../url-state/convertURLToExplorePreset.ts | 26 + .../dashboards/url-state/url-params.ts | 1 + .../dashboards/workspace/Dashboard.svelte | 6 + .../ScheduledReportDialog.svelte | 3 + web-common/src/lib/i18n/messages/en.json | 13 + web-common/src/lib/i18n/messages/es.json | 13 + .../src/proto/gen/rill/ui/v1/dashboard_pb.ts | 74 +++ .../canvas/kpi-grid-measure-selector.spec.ts | 29 + .../explores/pivot-ephemeral-measures.spec.ts | 141 ++++ 104 files changed, 4062 insertions(+), 259 deletions(-) create mode 100644 web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte create mode 100644 web-common/src/features/components/charts/ephemeral-measures.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/CreateEphemeralMeasureButton.svelte create mode 100644 web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte create mode 100644 web-common/src/features/dashboards/ephemeral-measures/canvas.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/dialog-store.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/types.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/url-param.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/url-state.ts create mode 100644 web-common/src/features/dashboards/ephemeral-measures/validation.ts create mode 100644 web-local/tests/canvas/kpi-grid-measure-selector.spec.ts create mode 100644 web-local/tests/explores/pivot-ephemeral-measures.spec.ts diff --git a/proto/rill/ui/v1/dashboard.proto b/proto/rill/ui/v1/dashboard.proto index 6a7bf6e9319f..f05187d8f302 100644 --- a/proto/rill/ui/v1/dashboard.proto +++ b/proto/rill/ui/v1/dashboard.proto @@ -159,6 +159,9 @@ message DashboardState { // Per-measure conditional formatting (heatmap / data bar) for pivot cells. repeated PivotConditionalFormat pivot_conditional_formatting = 44; + + // Ephemeral measures defined for the explore. + repeated EphemeralMeasure ephemeral_measures = 45; } message DashboardTimeRange { @@ -184,6 +187,19 @@ message PivotElement { } } +// An ephemeral measure defined ad-hoc for an explore dashboard, +// derived from existing metrics view measures via an arithmetic expression. +message EphemeralMeasure { + // Query alias, e.g. "profit". Must not collide with metrics view field names. + string name = 1; + // Display name shown in the UI, e.g. "Profit". + string display_name = 2; + // Arithmetic expression over existing measure names, e.g. "revenue - cost". + string expression = 3; + // Optional format preset for rendering values. + string format_preset = 4; +} + // Conditional formatting applied to a measure's cells in a pivot table. message PivotConditionalFormat { string measure = 1; diff --git a/web-admin/src/features/public-urls/form-utils.ts b/web-admin/src/features/public-urls/form-utils.ts index a23de7334079..70ee27b2115d 100644 --- a/web-admin/src/features/public-urls/form-utils.ts +++ b/web-admin/src/features/public-urls/form-utils.ts @@ -1,3 +1,4 @@ +import { parseMeasureExpression } from "@rilldata/web-common/features/dashboards/ephemeral-measures/expression-parser"; import { PivotChipType } from "@rilldata/web-common/features/dashboards/pivot/types"; import { getProtoFromDashboardState } from "@rilldata/web-common/features/dashboards/proto-state/toProto"; import { getAllIdentifiers } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; @@ -77,10 +78,29 @@ export function getSanitizedExploreStateParam( return getProtoFromDashboardState(exploreState, exploreSpec); // Else, explicitly add the sanitized state that we want to remember. + // Ephemeral measures are kept only when every measure they reference is + // visible to the recipient, so hidden fields cannot leak through expressions. + const sanitizedEphemeralMeasures = exploreState.ephemeralMeasures?.filter( + (def) => { + const parsed = parseMeasureExpression(def.expression); + return ( + !parsed.error && + parsed.refs.every((ref) => metricsViewFields.includes(ref)) + ); + }, + ); + const sanitizedEphemeralNames = new Set( + sanitizedEphemeralMeasures?.map((def) => def.name) ?? [], + ); const sanitizedDashboardState = { + ephemeralMeasures: sanitizedEphemeralMeasures?.length + ? sanitizedEphemeralMeasures + : undefined, // Remove any measures not specified in the metrics view fields - visibleMeasures: exploreState.visibleMeasures.filter((measure) => - metricsViewFields?.includes(measure), + visibleMeasures: exploreState.visibleMeasures.filter( + (measure) => + metricsViewFields?.includes(measure) || + sanitizedEphemeralNames.has(measure), ), allMeasuresVisible: exploreState.allMeasuresVisible, // Remove any dimensions not specified in the metrics view fields diff --git a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte index 6690012c67bb..44f4faf361ee 100644 --- a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte +++ b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte @@ -4,6 +4,7 @@ import DragHandle from "@rilldata/web-common/components/icons/DragHandle.svelte"; import EyeIcon from "@rilldata/web-common/components/icons/Eye.svelte"; import EyeOffIcon from "@rilldata/web-common/components/icons/EyeInvisible.svelte"; + import { PencilIcon } from "lucide-svelte"; import * as Popover from "@rilldata/web-common/components/popover"; import type { MetricsViewSpecDimension, @@ -34,8 +35,20 @@ export let allItems: SelectableItem[] = []; export let tagIndex: TagIndex; export let type: "measure" | "dimension" = "measure"; + // ephemeral measures: marks their rows with an fx icon; their + // description tooltip carries the calculation. + export let ephemeralNames: Set = new Set(); + // When set, ephemeral rows get an edit button that closes the menu and + // invokes this with the measure name. + export let onEditEphemeral: ((name: string) => void) | undefined = undefined; export let onSelectedChange: (items: string[]) => void; + function editEphemeral(e: Event, name: string) { + e.stopPropagation(); + active = false; + onEditEphemeral?.(name); + } + let searchText = ""; let active = false; let selectedTag: string | null = null; @@ -338,7 +351,23 @@ class="truncate min-w-0 flex-1 text-left pointer-events-none text-fg-primary" > {displayName} + {#if ephemeralNames.has(item.id)} + + ƒx + + {/if} + {#if onEditEphemeral && ephemeralNames.has(item.id)} + + {/if} + {/if} + {/if} + {/if} + {/if} + + {:else} + { + if (requireSelection && singleSelection && selected) return; + + onSelect(name); + }} > - {#if label.length > 240} - {label.slice(0, 240)}... - {:else} - {label} + + {#if label.length > 240} + {label.slice(0, 240)}... + {:else} + {label} + {/if} + {#if ephemeral} + ƒx + {/if} + + {#if ephemeral && onEditItem} + {/if} - - + + {/if} {:else}
- {#if allowSelectAll && allowMultiSelect} + {#if (allowSelectAll && allowMultiSelect) || $$slots.action}
- + {#if allowSelectAll && allowMultiSelect} + + {/if} - {#if numSelectedNotShown && showHiddenSelectionsCount} + {#if allowSelectAll && allowMultiSelect && numSelectedNotShown && showHiddenSelectionsCount}
{m.common_other_values_selected({ count: numSelectedNotShown })}
diff --git a/web-common/src/features/alerts/create-alert-utils.ts b/web-common/src/features/alerts/create-alert-utils.ts index 73da6f3c9b03..5edf34d5a777 100644 --- a/web-common/src/features/alerts/create-alert-utils.ts +++ b/web-common/src/features/alerts/create-alert-utils.ts @@ -23,18 +23,33 @@ export function getNewAlertInitialFormValues( ? (exploreState.selectedComparisonDimension ?? "") : (exploreState.selectedDimensionName ?? ""); + // Ephemeral measures cannot back an alert: the saved query has + // no definition to resolve the name against. Fall back to no prefill. + const ephemeralMeasureNames = new Set( + exploreState.ephemeralMeasures?.map((def) => def.name) ?? [], + ); + const preferredMeasure = + exploreState.tdd?.expandedMeasureName || + exploreState.leaderboardSortByMeasureName || + ""; + const measure = ephemeralMeasureNames.has(preferredMeasure) + ? "" + : preferredMeasure; + const criteriaMeasure = ephemeralMeasureNames.has( + exploreState.leaderboardSortByMeasureName ?? "", + ) + ? "" + : (exploreState.leaderboardSortByMeasureName ?? ""); + return { name: "", - measure: - exploreState.tdd?.expandedMeasureName || - exploreState.leaderboardSortByMeasureName || - "", + measure, splitByDimension: dimension, evaluationInterval: "", criteria: [ { ...getEmptyMeasureFilterEntry(), - measure: exploreState.leaderboardSortByMeasureName ?? "", + measure: criteriaMeasure, }, ], criteriaOperation: V1Operation.OPERATION_AND, diff --git a/web-common/src/features/canvas/components/charts/BaseChart.ts b/web-common/src/features/canvas/components/charts/BaseChart.ts index 9338a9bd8a67..d9be32aea1d1 100644 --- a/web-common/src/features/canvas/components/charts/BaseChart.ts +++ b/web-common/src/features/canvas/components/charts/BaseChart.ts @@ -91,6 +91,12 @@ export abstract class BaseChart< return { options: { metrics_view: { type: "metrics", label: m.canvas_metrics_view_label() }, + // Managed through the measure selectors' create/edit dialog. + ephemeral_measures: { + type: "ephemeral_measures", + optional: true, + showInUI: false, + }, tooltip: { type: "tooltip", label: m.canvas_tooltip_label(), @@ -236,6 +242,7 @@ export abstract class BaseChart< ): Partial { const { metrics_view, + ephemeral_measures, title, description, vl_config, @@ -268,6 +275,7 @@ export abstract class BaseChart< return { metrics_view, + ...(ephemeral_measures ? { ephemeral_measures } : {}), title, description, vl_config, diff --git a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte index 9cbef500dd6c..6dfea51301c6 100644 --- a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte +++ b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte @@ -23,6 +23,7 @@ $: kpis = (kpiGridProperties.measures || []).map((measure) => ({ metrics_view: kpiGridProperties.metrics_view, measure, + ephemeral_measures: kpiGridProperties.ephemeral_measures, sparkline: kpiGridProperties.sparkline, hide_time_range: kpiGridProperties.hide_time_range, comparison: kpiGridProperties.comparison, diff --git a/web-common/src/features/canvas/components/kpi-grid/index.ts b/web-common/src/features/canvas/components/kpi-grid/index.ts index fd1991830810..74a290feed25 100644 --- a/web-common/src/features/canvas/components/kpi-grid/index.ts +++ b/web-common/src/features/canvas/components/kpi-grid/index.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent"; import { getCommonOptions, @@ -35,6 +36,8 @@ export interface KPIGridSpec ComponentFilterProperties { metrics_view: string; measures: string[]; + // Ad-hoc measures derived from existing measures via an arithmetic expression. + ephemeral_measures?: EphemeralMeasureSpec[]; // Defaults to "bottom" sparkline?: "none" | "bottom" | "right"; // Defaults to false if undefined; @@ -46,7 +49,7 @@ export interface KPIGridSpec export class KPIGridComponent extends BaseCanvasComponent { minSize = { width: 2, height: 2 }; defaultSize = { width: 6, height: 4 }; - resetParams = ["measures"]; + resetParams = ["measures", "ephemeral_measures"]; type: CanvasComponentType = "kpi_grid"; component = KPIGrid; @@ -103,6 +106,13 @@ export class KPIGridComponent extends BaseCanvasComponent { meta: { allowedTypes: ["measure"] }, label: m.canvas_measures_label(), }, + ephemeral_measures: { + type: "ephemeral_measures", + label: m.canvas_ephemeral_measures_label(), + optional: true, + // Managed through the measures selector's create/edit dialog. + showInUI: false, + }, ...(hasTimeSeries ? timeSeriesOptions : {}), ...getCommonOptions(), }, diff --git a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte index 2667e52039e2..cc2c509ef2c9 100644 --- a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte +++ b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte @@ -1,4 +1,10 @@ + + { + if (!open) onClose(); + }} +> + + + + {editingDef + ? m.dashboard_pivot_ephemeral_edit_title() + : m.dashboard_pivot_ephemeral_new_title()} + + + +
+ + + + + {#if referenceableMeasures.length} +
+ + {m.dashboard_pivot_ephemeral_insert_measure()} + +
+ {#each referenceableMeasures as mes (mes.name)} + + {/each} +
+
+ {/if} + + + + {#if saveError} +

{saveError}

+ {/if} +
+ + + {#if editingDef} +
+ +
+ {/if} + + +
+
+
diff --git a/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte b/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte index a9a49ea93e31..73c02aebded7 100644 --- a/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte +++ b/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte @@ -1,9 +1,12 @@
@@ -38,7 +62,17 @@ {selectedItems} {types} {excludedValues} + {ephemeralMeasures} {onMultiSelect} + onCreateEphemeral={ephemeralEnabled + ? () => openEphemeralEditor(null) + : undefined} + onEditEphemeral={ephemeralEnabled + ? (name) => { + const def = ephemeralMeasures?.find((d) => d.name === name); + if (def) openEphemeralEditor(def); + } + : undefined} bind:open bind:searchValue > @@ -58,3 +92,14 @@
+ +{#if ephemeralEditorOpen && component} + (ephemeralEditorOpen = false)} + onCreated={(name) => onMultiSelect([...selectedItems, name])} + /> +{/if} diff --git a/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte b/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte index 1e6a4b87ca97..af084d5047bd 100644 --- a/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte +++ b/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte @@ -3,9 +3,14 @@ import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu"; import InputLabel from "@rilldata/web-common/components/forms/InputLabel.svelte"; import Search from "@rilldata/web-common/components/search/Search.svelte"; + import type { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent"; import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; + import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; + import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; + import { PencilIcon, PlusIcon } from "lucide-svelte"; import { useMetricFieldData } from "../selectors"; + import EphemeralMeasureEditor from "./EphemeralMeasureEditor.svelte"; const client = useRuntimeClient(); @@ -18,12 +23,30 @@ export let canvasName: string; export let searchableItems: string[] | undefined = undefined; export let excludedValues: string[] | undefined = undefined; + export let ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined; + // Component owning the spec; when set (and this is a measure field), the + // selector offers creating and editing ephemeral measures. + export let component: BaseCanvasComponent | undefined = undefined; export let isRemovable = false; export let onSelect: (item: string, displayName: string) => void = () => {}; export let onRemove: () => void = () => {}; let open = false; let searchValue = ""; + let ephemeralEditorOpen = false; + let ephemeralEditingDef: EphemeralMeasureDef | null = null; + + $: ephemeralEnabled = !!component && type === "measure"; + + function openEphemeralEditor(def: EphemeralMeasureDef | null) { + ephemeralEditingDef = def; + ephemeralEditorOpen = true; + open = false; + } + + $: ephemeralDefsByName = new Map( + (ephemeralMeasures ?? []).map((def) => [def.name, def]), + ); $: ctx = getCanvasStore(canvasName, client.instanceId); $: ({ getTimeDimensionForMetricView } = ctx.canvasEntity.metricsView); @@ -42,6 +65,7 @@ searchableItems, searchValue, effectiveExcludedValues, + type === "measure" ? ephemeralMeasures : undefined, ); @@ -64,7 +88,7 @@ type={isTimeSelected ? "time" : type} > @@ -72,6 +96,11 @@ Time {:else if selectedItem} {$fieldData.displayMap[selectedItem]?.label || selectedItem} + {#if ephemeralDefsByName.has(selectedItem)} + + ƒx + + {/if} {:else} Choose a field... {/if} @@ -99,6 +128,7 @@ {/if} {#each $fieldData.filteredItems as item (item)} {#if item !== selectedItem} + {@const ephemeralDef = ephemeralDefsByName.get(item)} { @@ -107,7 +137,30 @@ }} > - {$fieldData.displayMap[item]?.label || item} + + {$fieldData.displayMap[item]?.label || item} + {#if ephemeralDef} + ƒx + {#if ephemeralEnabled} + + {/if} + {/if} + {/if} @@ -119,6 +172,29 @@ {/if} {/each}
+ {#if ephemeralEnabled} +
+ +
+ {/if} + +{#if ephemeralEditorOpen && component} + (ephemeralEditorOpen = false)} + onCreated={(name, displayName) => onSelect(name, displayName || name)} + /> +{/if} diff --git a/web-common/src/features/canvas/inspector/selectors.ts b/web-common/src/features/canvas/inspector/selectors.ts index 42f249106f5b..af162d954037 100644 --- a/web-common/src/features/canvas/inspector/selectors.ts +++ b/web-common/src/features/canvas/inspector/selectors.ts @@ -1,3 +1,5 @@ +import { appendEphemeralSpecMeasures } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; +import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import type { FieldType } from "@rilldata/web-common/features/canvas/inspector/types"; import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import { @@ -25,6 +27,8 @@ export function useMetricFieldData( searchableItems: string[] | undefined = undefined, searchValue = "", excludedValues: string[] | undefined = undefined, + // ephemeral measures defined on the component spec. + ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined, ) { const { metricsView, timeManager } = ctx.canvasEntity; @@ -42,11 +46,15 @@ export function useMetricFieldData( const timeDimension = metricsViewSpec?.timeDimension; if (type.includes("measure")) { - items = measures.map((m) => m.name as string); + const allMeasures = appendEphemeralSpecMeasures( + measures, + ephemeralMeasures, + ); + items = allMeasures.map((m) => m.name as string); Object.assign( displayMap, Object.fromEntries( - measures.map((item) => [ + allMeasures.map((item) => [ item.name as string, { label: getMeasureDisplayName(item), type: "measure" }, ]), diff --git a/web-common/src/features/canvas/inspector/types.ts b/web-common/src/features/canvas/inspector/types.ts index e6dec512c553..488ebfac83c0 100644 --- a/web-common/src/features/canvas/inspector/types.ts +++ b/web-common/src/features/canvas/inspector/types.ts @@ -10,6 +10,7 @@ type SemanticInputTypes = | "dimension" | "multi_fields" | "multi_fields_format" + | "ephemeral_measures" | "metrics_sql"; type NativeInputTypes = "text" | "number" | "boolean" | "textarea" | "select"; type ChartInputTypes = "positional" | "mark" | "tooltip" | "config"; diff --git a/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts b/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts index f44fa1101381..f9b2f7cb086a 100644 --- a/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts +++ b/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import { ChartSortType, type ChartDataQuery, @@ -25,6 +26,10 @@ import type { V1TimeRange, } from "@rilldata/web-common/runtime-client"; import { getQueryServiceMetricsViewAggregationQueryOptions } from "@rilldata/web-common/runtime-client"; +import { + chartEphemeralMeasureNames, + withEphemeralMeasures, +} from "../ephemeral-measures"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { @@ -44,6 +49,9 @@ import { export type CartesianChartSpec = { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"nominal" | "time">; y?: FieldConfig<"quantitative">; color?: FieldConfig<"nominal"> | string; @@ -178,6 +186,8 @@ export class CartesianChartProvider { measures = [{ name: config.y.field }]; } } + measures = withEphemeralMeasures(config, measures); + const ephemeralNames = chartEphemeralMeasureNames(config); let limit: number | undefined; let hasColorDimension = false; @@ -232,7 +242,7 @@ export class CartesianChartProvider { !!comparisonTimeRange?.start && !!comparisonTimeRange?.end; - const xAxisSort = this.resolveXAxisSort( + let xAxisSort = this.resolveXAxisSort( config, isMultiMeasure, isComparisonActive, @@ -247,7 +257,14 @@ export class CartesianChartProvider { ? config.y?.fields?.[0] : config.y?.field; - if (sortMeasureName) { + if (sortMeasureName && ephemeralNames.has(sortMeasureName)) { + // Comparison deltas cannot be computed for ephemeral measures; + // fall back to sorting by the measure itself. + xAxisSort = { + name: sortMeasureName, + desc: xAxisSort?.desc ?? true, + }; + } else if (sortMeasureName) { const deltaFieldName = sortMeasureName + ComparisonDeltaAbsoluteSuffix; topNMeasures = [ @@ -420,7 +437,12 @@ export class CartesianChartProvider { const measuresWithComparison: V1MetricsViewAggregationMeasure[] = Array.from(measuresSet) .map((measureName) => { - if (showTimeComparison && comparisonTimeRange?.start) { + // Comparison values cannot be computed for ephemeral measures. + if ( + showTimeComparison && + comparisonTimeRange?.start && + !ephemeralNames.has(measureName) + ) { const result: V1MetricsViewAggregationMeasure[] = [ { name: measureName }, { @@ -440,7 +462,7 @@ export class CartesianChartProvider { client, { metricsView: config.metrics_view, - measures: measuresWithComparison, + measures: withEphemeralMeasures(config, measuresWithComparison), dimensions, where: combinedWhere, timeRange, diff --git a/web-common/src/features/components/charts/circular/CircularChartProvider.ts b/web-common/src/features/components/charts/circular/CircularChartProvider.ts index e33400bcfba3..ff6654be94f1 100644 --- a/web-common/src/features/components/charts/circular/CircularChartProvider.ts +++ b/web-common/src/features/components/charts/circular/CircularChartProvider.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import { ChartSortType, type ChartDataQuery, @@ -27,6 +28,7 @@ import { type Readable, type Writable, } from "svelte/store"; +import { withEphemeralMeasures } from "../ephemeral-measures"; import { canQueryWithTimeRange, getFilterWithNullHandling, @@ -40,6 +42,9 @@ import { export type CircularChartSpec = { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; measure?: FieldConfig<"quantitative">; color?: FieldConfig<"nominal">; innerRadius?: number; @@ -90,7 +95,9 @@ export class CircularChartProvider { let dimensions: V1MetricsViewAggregationDimension[] = []; if (config.measure?.field) { - measures = [{ name: config.measure.field }]; + measures = withEphemeralMeasures(config, [ + { name: config.measure.field }, + ]); } let colorSort: V1MetricsViewAggregationSort | undefined; diff --git a/web-common/src/features/components/charts/combo/ComboChartProvider.ts b/web-common/src/features/components/charts/combo/ComboChartProvider.ts index c0b8309f0fbf..ef8d204f89df 100644 --- a/web-common/src/features/components/charts/combo/ComboChartProvider.ts +++ b/web-common/src/features/components/charts/combo/ComboChartProvider.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import { ChartSortType, type ChartDataQuery, @@ -31,11 +32,15 @@ import { getFilterWithNullHandling, vegaSortToAggregationSort, } from "../query-util"; +import { withEphemeralMeasures } from "../ephemeral-measures"; export type MarkType = "bar" | "line"; export type ComboChartSpec = { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"nominal" | "time">; y1?: FieldConfig<"quantitative" | "mark">; y2?: FieldConfig<"quantitative" | "mark">; @@ -90,7 +95,7 @@ export class ComboChartProvider { const visibleStore = visible ?? readable(true); const config = get(this.spec); - const measures: V1MetricsViewAggregationMeasure[] = []; + let measures: V1MetricsViewAggregationMeasure[] = []; let dimensions: V1MetricsViewAggregationDimension[] = []; // Add both y1 and y2 measures @@ -100,6 +105,7 @@ export class ComboChartProvider { if (config.y2?.type === "quantitative" && config.y2?.field) { measures.push({ name: config.y2.field }); } + measures = withEphemeralMeasures(config, measures); const dimensionName = config.x?.field; const requiresTimeRange = config.x?.type === "temporal"; @@ -124,7 +130,7 @@ export class ComboChartProvider { } const xAxisMeasures = config.y1?.field - ? [{ name: config.y1.field }] + ? withEphemeralMeasures(config, [{ name: config.y1.field }]) : []; const xAxisSort = vegaSortToAggregationSort( diff --git a/web-common/src/features/components/charts/data-provider.ts b/web-common/src/features/components/charts/data-provider.ts index af4023e061c2..7b0d86f32430 100644 --- a/web-common/src/features/components/charts/data-provider.ts +++ b/web-common/src/features/components/charts/data-provider.ts @@ -17,6 +17,7 @@ import type { ChartSpec, TimeDimensionDefinition, } from "./types"; +import { resolveEphemeralMeasureSpec } from "./ephemeral-measures"; import { adjustDataForTimeZone, getFieldsByType } from "./util"; export interface ChartDataDependencies { @@ -91,7 +92,13 @@ export function getChartData( ([chartData, $timeAndFilterStore, theme, isThemeModeDark, ...fieldMap]) => { const fieldSpecMap = allFields.reduce( (acc, field, index) => { - acc[field.field] = fieldMap?.[index]; + acc[field.field] = + fieldMap?.[index] ?? + // ephemeral measures have no metrics view spec entry; + // synthesize one so labels and formatters resolve. + (field.type === "measure" + ? resolveEphemeralMeasureSpec(config, field.field) + : undefined); return acc; }, {} as Record< @@ -182,7 +189,9 @@ export function getFieldsForSpec( >; measures.forEach((measure) => { - fields[measure] = metricsView.measures?.find((m) => m.name === measure); + fields[measure] = + metricsView.measures?.find((m) => m.name === measure) ?? + resolveEphemeralMeasureSpec(config, measure); }); dimensions.forEach((dimension) => { diff --git a/web-common/src/features/components/charts/ephemeral-measures.ts b/web-common/src/features/components/charts/ephemeral-measures.ts new file mode 100644 index 000000000000..efdf25c8f2b3 --- /dev/null +++ b/web-common/src/features/components/charts/ephemeral-measures.ts @@ -0,0 +1,56 @@ +import { ephemeralSpecsToDefs } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; +import { + ephemeralMeasureToSpecMeasure, + mapEphemeralMeasuresForRequest, +} from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; +import type { + MetricsViewSpecMeasure, + V1MetricsViewAggregationMeasure, +} from "@rilldata/web-common/runtime-client"; +import type { CommonChartProperties } from "./types"; + +type EphemeralMeasureCarrier = Pick< + CommonChartProperties, + "ephemeral_measures" +>; + +/** + * Attaches the `expression` compute to request measures whose name matches one + * of the chart's ephemeral measures. Leaves other measures untouched. + */ +export function withEphemeralMeasures( + config: EphemeralMeasureCarrier, + measures: V1MetricsViewAggregationMeasure[], +): V1MetricsViewAggregationMeasure[] { + return mapEphemeralMeasuresForRequest( + measures, + ephemeralSpecsToDefs(config.ephemeral_measures), + ); +} + +/** + * Names of the chart's ephemeral measures, for quick membership checks. + */ +export function chartEphemeralMeasureNames( + config: EphemeralMeasureCarrier, +): Set { + return new Set( + ephemeralSpecsToDefs(config.ephemeral_measures)?.map((def) => def.name) ?? + [], + ); +} + +/** + * Synthesizes a metrics view spec measure for an ephemeral measure name so + * labels, formatters and tooltips resolve like for any other measure. Returns + * undefined when the name is not one of the chart's ephemeral measures. + */ +export function resolveEphemeralMeasureSpec( + config: EphemeralMeasureCarrier, + name: string, +): MetricsViewSpecMeasure | undefined { + const def = ephemeralSpecsToDefs(config.ephemeral_measures)?.find( + (d) => d.name === name, + ); + return def ? ephemeralMeasureToSpecMeasure(def) : undefined; +} diff --git a/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts b/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts index 2a65f9fca111..3b8f927c0987 100644 --- a/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts +++ b/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import { ChartSortType, type ChartDataQuery, @@ -29,6 +30,7 @@ import { canQueryWithTimeRange, getFilterWithNullHandling, } from "../query-util"; +import { withEphemeralMeasures } from "../ephemeral-measures"; export type FunnelMode = "width" | "order"; export type FunnelColorMode = "stage" | "measure" | "name" | "value"; @@ -37,6 +39,9 @@ export type FunnelPercentMode = "top" | "previous"; export type FunnelChartSpec = { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; breakdownMode?: FunnelBreakdownMode; measure?: FieldConfig<"quantitative">; stage?: FieldConfig<"nominal">; @@ -105,6 +110,7 @@ export class FunnelChartProvider { measures = [{ name: config.measure.field }]; } } + measures = withEphemeralMeasures(config, measures); let stageSort: V1MetricsViewAggregationSort | undefined; let limit: number | undefined; diff --git a/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts b/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts index 84280efdca34..c035f07e706f 100644 --- a/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts +++ b/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import { ChartSortType, type ChartDataQuery, @@ -30,9 +31,13 @@ import { getFilterWithNullHandling, vegaSortToAggregationSort, } from "../query-util"; +import { withEphemeralMeasures } from "../ephemeral-measures"; export type HeatmapChartSpec = { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"nominal" | "time">; y?: FieldConfig<"nominal" | "time">; color?: FieldConfig<"quantitative">; @@ -80,7 +85,7 @@ export class HeatmapChartProvider { let measures: V1MetricsViewAggregationMeasure[] = []; if (config.color?.field) { - measures = [{ name: config.color.field }]; + measures = withEphemeralMeasures(config, [{ name: config.color.field }]); } // Create top level options store for X axis diff --git a/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts b/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts index 3e4467cc1512..232597559bbf 100644 --- a/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts +++ b/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import type { ChartDataQuery, ChartDomainValues, @@ -28,9 +29,13 @@ import { canQueryWithTimeRange, getFilterWithNullHandling, } from "../query-util"; +import { withEphemeralMeasures } from "../ephemeral-measures"; export type ScatterPlotChartSpec = { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"quantitative" | "time">; y?: FieldConfig<"quantitative" | "time">; dimension?: FieldConfig<"nominal">; @@ -60,7 +65,7 @@ export class ScatterPlotChartProvider { const visibleStore = visible ?? readable(true); const config = get(this.spec); - const measures: V1MetricsViewAggregationMeasure[] = []; + let measures: V1MetricsViewAggregationMeasure[] = []; const dimensions: V1MetricsViewAggregationDimension[] = []; if (config.x?.field) { @@ -82,6 +87,7 @@ export class ScatterPlotChartProvider { if (config.size?.type === "quantitative" && config.size?.field) { measures.push({ name: config.size.field }); } + measures = withEphemeralMeasures(config, measures); if (config.dimension?.type === "nominal" && config.dimension?.field) { dimensions.push({ name: config.dimension.field }); diff --git a/web-common/src/features/components/charts/types.ts b/web-common/src/features/components/charts/types.ts index 1e0452b6cae7..b0f5b5d0fd46 100644 --- a/web-common/src/features/components/charts/types.ts +++ b/web-common/src/features/components/charts/types.ts @@ -12,6 +12,7 @@ import type { ScatterPlotChartProvider, ScatterPlotChartSpec, } from "@rilldata/web-common/features/components/charts"; +import type { EphemeralMeasureSpec } from "@rilldata/web-common/features/dashboards/ephemeral-measures/canvas"; import type { V1Expression, V1MetricsViewAggregationDimension, @@ -190,6 +191,9 @@ export type FieldConfig< export interface CommonChartProperties { metrics_view: string; + // Ad-hoc measures derived from existing measures via an arithmetic + // expression; measure fields may name them. + ephemeral_measures?: EphemeralMeasureSpec[]; tooltip?: FieldConfig; vl_config?: string; } diff --git a/web-common/src/features/dashboards/aggregation-request-utils.ts b/web-common/src/features/dashboards/aggregation-request-utils.ts index e7948991536b..3fcda966d689 100644 --- a/web-common/src/features/dashboards/aggregation-request-utils.ts +++ b/web-common/src/features/dashboards/aggregation-request-utils.ts @@ -1,3 +1,8 @@ +import { + ephemeralMeasureNameSet, + mapEphemeralMeasuresForRequest, +} from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; +import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { getAggregationDimensionFromFieldName } from "@rilldata/web-common/features/dashboards/aggregation-request/dimension-utils.ts"; import { getComparisonRequestMeasures } from "@rilldata/web-common/features/dashboards/dashboard-utils.ts"; import { MeasureModifierSuffixRegex } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts"; @@ -78,30 +83,41 @@ export const aggregationRequestWithRowsAndColumns = ({ columns, showTimeComparison, selectedTimezone, + ephemeralMeasures, }: { exploreSpec: V1ExploreSpec; rows: string[]; columns: string[]; showTimeComparison: boolean; selectedTimezone: string; + ephemeralMeasures?: EphemeralMeasureDef[]; }) => { return (aggregationRequest: V1MetricsViewAggregationRequest) => { const isFlat = rows.length === 0; + const ephemeralMeasureNames = ephemeralMeasureNameSet(ephemeralMeasures); + const isMeasureColumn = (col: string) => + exploreSpec.measures?.includes(col) || ephemeralMeasureNames.has(col); // Get measures defined as columns. We do allow adding measures as rows so need to check it. - const measures = columns - .filter((col) => exploreSpec.measures?.includes(col)) - .flatMap((measureName) => { + const measures = mapEphemeralMeasuresForRequest( + columns.filter(isMeasureColumn).flatMap((measureName) => { const group: V1MetricsViewAggregationMeasure[] = [ { name: measureName }, ]; - if (showTimeComparison && aggregationRequest.comparisonTimeRange) { + // Comparison computes are not supported for ephemeral measures. + if ( + showTimeComparison && + aggregationRequest.comparisonTimeRange && + !ephemeralMeasureNames.has(measureName) + ) { group.push(...getComparisonRequestMeasures(measureName)); } return group; - }); + }), + ephemeralMeasures, + ); // Get dimensions defined as rows const dimensionsFromRows: V1MetricsViewAggregationDimension[] = rows.map( @@ -110,7 +126,7 @@ export const aggregationRequestWithRowsAndColumns = ({ // Get dimensions defined as columns const dimensionsFromColumns: V1MetricsViewAggregationDimension[] = columns - .filter((col) => !exploreSpec.measures?.includes(col)) + .filter((col) => !isMeasureColumn(col)) .map((col) => getAggregationDimensionFromFieldName(col, selectedTimezone), ); diff --git a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte index 241e69be342e..5a85fb264795 100644 --- a/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte +++ b/web-common/src/features/dashboards/big-number/MeasureBigNumber.svelte @@ -27,10 +27,15 @@ type CrossfadeParams, type FlyParams, } from "svelte/transition"; + import { mapEphemeralMeasuresForRequest } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; + import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { cellInspectorStore } from "../stores/cell-inspector-store"; import BigNumberTooltipContent from "./BigNumberTooltipContent.svelte"; export let measure: MetricsViewSpecMeasure; + // ephemeral measure definitions; when `measure` is one of + // them, the request carries its `expression` compute. + export let ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined; export let withTimeseries = true; export let isMeasureExpanded = false; export let metricsViewName: string; @@ -47,6 +52,10 @@ const client = useRuntimeClient(); $: measureName = measure.name ?? ""; + $: requestMeasures = mapEphemeralMeasuresForRequest( + [{ name: measureName }], + ephemeralMeasures, + ); // Measures with required dimensions (e.g. a rolling window ordered by the time // dimension) produce one value per dimension value and have no single total, @@ -58,7 +67,7 @@ client, { metricsView: metricsViewName, - measures: [{ name: measureName }], + measures: requestMeasures, where, timeRange: { start: timeStart as any, @@ -84,7 +93,7 @@ client, { metricsView: metricsViewName, - measures: [{ name: measureName }], + measures: requestMeasures, where, timeRange: { start: comparisonTimeStart as any, @@ -382,15 +391,19 @@ @apply shadow-md outline-1 outline; outline-color: color-mix( in oklab, - var(--color-theme-500) calc(0.15 * 100%), + var(--color-theme-500) ephemeral(0.15 * 100%), transparent ); background: linear-gradient( to bottom, - color-mix(in oklab, var(--white) calc(0.15 * 100%), transparent), + color-mix(in oklab, var(--white) ephemeral(0.15 * 100%), transparent), 50%, - color-mix(in oklab, var(--color-theme-300) calc(0.1 * 100%), transparent) + color-mix( + in oklab, + var(--color-theme-300) ephemeral(0.1 * 100%), + transparent + ) ); } @@ -398,9 +411,9 @@ @apply shadow-md outline-1 outline outline-[#FFFFFF26]; background: linear-gradient( to bottom, - color-mix(in oklab, var(--white) calc(0.1 * 100%), transparent), + color-mix(in oklab, var(--white) ephemeral(0.1 * 100%), transparent), 50%, - color-mix(in oklab, var(--white) calc(0.05 * 100%), transparent) + color-mix(in oklab, var(--white) ephemeral(0.05 * 100%), transparent) ); } diff --git a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte index c2bcd773d327..d38989d84baf 100644 --- a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte +++ b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte @@ -1,4 +1,7 @@ + + + + diff --git a/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte new file mode 100644 index 000000000000..ad1fd15b9781 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte @@ -0,0 +1,229 @@ + + + { + if (!open) close(); + }} +> + + + + {editingDef + ? m.dashboard_pivot_ephemeral_edit_title() + : m.dashboard_pivot_ephemeral_new_title()} + + + +
+ + + + + {#if referenceableMeasures.length} +
+ + {m.dashboard_pivot_ephemeral_insert_measure()} + +
+ {#each referenceableMeasures as mes (mes.name)} + + {/each} +
+
+ {/if} + + + + {#if saveError} +

{saveError}

+ {/if} +
+ + + {#if editingDef} +
+ +
+ {/if} + + +
+
+
diff --git a/web-common/src/features/dashboards/ephemeral-measures/canvas.ts b/web-common/src/features/dashboards/ephemeral-measures/canvas.ts new file mode 100644 index 000000000000..90c790bd861f --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/canvas.ts @@ -0,0 +1,34 @@ +import type { EphemeralMeasureDef } from "./types"; + +// YAML shape of an ephemeral measure in a canvas component spec (snake_case). +export interface EphemeralMeasureSpec { + name: string; + display_name: string; + expression: string; + format_preset?: string; +} + +export function ephemeralSpecsToDefs( + specs: EphemeralMeasureSpec[] | undefined, +): EphemeralMeasureDef[] | undefined { + if (!specs?.length) return undefined; + return specs + .filter((spec) => spec?.name && spec?.expression) + .map((spec) => ({ + name: spec.name, + displayName: spec.display_name || spec.name, + expression: spec.expression, + ...(spec.format_preset ? { formatPreset: spec.format_preset } : {}), + })); +} + +export function ephemeralDefsToSpecs( + defs: EphemeralMeasureDef[] | undefined, +): EphemeralMeasureSpec[] { + return (defs ?? []).map((def) => ({ + name: def.name, + display_name: def.displayName, + expression: def.expression, + ...(def.formatPreset ? { format_preset: def.formatPreset } : {}), + })); +} diff --git a/web-common/src/features/dashboards/ephemeral-measures/dialog-store.ts b/web-common/src/features/dashboards/ephemeral-measures/dialog-store.ts new file mode 100644 index 000000000000..8c8c935f5eed --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/dialog-store.ts @@ -0,0 +1,8 @@ +import { writable } from "svelte/store"; +import type { EphemeralMeasureDef } from "./types"; + +// State of the ephemeral measure dialog. null = closed; +// `def` present = editing an existing definition, otherwise creating a new one. +export const ephemeralMeasureDialog = writable<{ + def?: EphemeralMeasureDef; +} | null>(null); diff --git a/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts new file mode 100644 index 000000000000..d4e7304d10d3 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import type { V1MetricsViewAggregationMeasure } from "@rilldata/web-common/runtime-client"; +import { prepareMeasuresForRequest } from "../pivot/pivot-utils"; +import type { EphemeralMeasureDef } from "./types"; +import { + validateEphemeralMeasureDef, + validateEphemeralMeasureName, +} from "./validation"; + +const PROFIT: EphemeralMeasureDef = { + name: "profit", + displayName: "Profit", + expression: "revenue - cost", +}; + +describe("prepareMeasuresForRequest", () => { + it("attaches the expression compute to ephemeral measures", () => { + const measures: V1MetricsViewAggregationMeasure[] = [ + { name: "revenue" }, + { name: "profit" }, + ]; + expect(prepareMeasuresForRequest(measures, [PROFIT])).toEqual([ + { name: "revenue" }, + { + name: "profit", + expression: { expression: "revenue - cost", displayName: "Profit" }, + }, + ]); + }); + + it("still maps comparison suffixes for spec measures", () => { + const measures: V1MetricsViewAggregationMeasure[] = [ + { name: "revenue" }, + { name: "revenue__delta_abs" }, + { name: "revenue__delta_rel" }, + { name: "profit" }, + ]; + expect(prepareMeasuresForRequest(measures, [PROFIT])).toEqual([ + { name: "revenue" }, + { name: "revenue__delta_abs", comparisonDelta: { measure: "revenue" } }, + { name: "revenue__delta_rel", comparisonRatio: { measure: "revenue" } }, + { + name: "profit", + expression: { expression: "revenue - cost", displayName: "Profit" }, + }, + ]); + }); + + it("is a no-op without ephemeral measures", () => { + const measures: V1MetricsViewAggregationMeasure[] = [{ name: "revenue" }]; + expect(prepareMeasuresForRequest(measures, undefined)).toEqual(measures); + }); +}); + +describe("validateEphemeralMeasureName", () => { + const reserved = new Set(["revenue", "domain"]); + + it.each([ + ["profit", undefined], + ["Profit_2", undefined], + ["2profit", "must start with a letter"], + ["pro fit", "must start with a letter"], + ["profit__delta_abs", "comparison suffix"], + ["profit__delta_rel", "comparison suffix"], + ["profit_prev", "comparison suffix"], + ["profit_delta", "comparison suffix"], + ["profit_delta_perc", "comparison suffix"], + ["profit_percent_of_total", "comparison suffix"], + ["profit_rill_day", '"_rill_"'], + ["revenue", "already used"], + ])("%s", (name, expected) => { + const error = validateEphemeralMeasureName(name, reserved); + if (expected === undefined) { + expect(error).toBeUndefined(); + } else { + expect(error).toContain(expected); + } + }); +}); + +describe("validateEphemeralMeasureDef", () => { + const known = new Set(["revenue", "cost"]); + const reserved = new Set(["revenue", "cost", "domain", "timestamp"]); + + it("accepts a valid definition", () => { + expect( + validateEphemeralMeasureDef(PROFIT, known, reserved), + ).toBeUndefined(); + }); + + it("rejects unknown measure references", () => { + const def = { ...PROFIT, expression: "revenue - expenses" }; + expect(validateEphemeralMeasureDef(def, known, reserved)).toContain( + '"expenses" is not a measure', + ); + }); + + it("rejects references to other ephemeral measures", () => { + // "profit2" is not in the known measure set, so a second ephemeral + // measure cannot reference the first. + const def = { + name: "margin", + displayName: "Margin", + expression: "profit2 / revenue", + }; + expect(validateEphemeralMeasureDef(def, known, reserved)).toContain( + '"profit2" is not a measure', + ); + }); + + it("rejects invalid expressions", () => { + const def = { ...PROFIT, expression: "sum(revenue)" }; + expect(validateEphemeralMeasureDef(def, known, reserved)).toContain( + "unsupported function", + ); + }); + + it("rejects an empty display name", () => { + const def = { ...PROFIT, displayName: " " }; + expect(validateEphemeralMeasureDef(def, known, reserved)).toContain( + "display name is required", + ); + }); +}); diff --git a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts new file mode 100644 index 000000000000..a7a204a17e59 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { + EPHEMERAL_MEASURE_FUNCTIONS, + MAX_EPHEMERAL_EXPRESSION_LENGTH, + formatMeasureRef, + parseMeasureExpression, +} from "./expression-parser"; + +describe("parseMeasureExpression", () => { + describe("accepts", () => { + const cases: Array<[string, string[]]> = [ + ["revenue - cost", ["revenue", "cost"]], + ["revenue-cost", ["revenue", "cost"]], + ["(a + b) * 2", ["a", "b"]], + ["-a", ["a"]], + ["a % b", ["a", "b"]], + ["a * -1", ["a"]], + ["power(a, 2)", ["a"]], + ["coalesce(a, 0)", ["a"]], + ["coalesce(a, NULL)", ["a"]], + ["nullif(cost, 0)", ["cost"]], + ["round(a / b, 2)", ["a", "b"]], + [ + "abs(a) + floor(b) + ceil(c) + sqrt(d) + ln(e) + exp(f)", + ["a", "b", "c", "d", "e", "f"], + ], + ["greatest(a, b, c)", ["a", "b", "c"]], + ['"count" - a', ["count", "a"]], + ['"weird""name" + 1', ['weird"name']], + ["1e6 * a", ["a"]], + ["0.4 * revenue", ["revenue"]], + ["revenue - revenue", ["revenue"]], + ["((((a))))", ["a"]], + ['"null" + 1', ["null"]], + ["ROUND(a, 2)", ["a"]], + ['"rank" + a', ["rank", "a"]], + ["true + a", ["a"]], + ]; + it.each(cases)("%s", (expr, refs) => { + const res = parseMeasureExpression(expr); + expect(res.error).toBeUndefined(); + expect(res.refs).toEqual(refs); + }); + }); + + describe("rejects", () => { + const cases: Array<[string, string]> = [ + ["", "empty"], + [" ", "empty"], + ["revenue); DROP TABLE x;--", "unexpected"], + ["a; SELECT 1", "unexpected"], + ["(select secret from t)", "reserved SQL word"], + ["sum(revenue)", 'unsupported function "sum"'], + ["count(a)", 'unsupported function "count"'], + ["t.revenue", "unexpected character"], + ["a > b", "unexpected"], + ["a and b", "reserved SQL word"], + ["'str'", "string literals"], + ["nullif(a, 'x')", "string literals"], + ["a || b", "unexpected"], + ["@@version", "unexpected character"], + ["foo(a)", 'unsupported function "foo"'], + ["round(a, 1, 2)", "does not accept 3 argument"], + ["coalesce(a)", "does not accept 1 argument"], + ["100", "must reference at least one measure"], + ["1 + 2", "must reference at least one measure"], + ["NULL", "must reference at least one measure"], + ["a as profit", "reserved SQL word"], + ["a /* comment */ + b", "unexpected"], + ['"unterminated', "unterminated quoted identifier"], + ["a +", "unexpected end of expression"], + ["(a", "expected closing parenthesis"], + ["revenue -- cost", "comments are not allowed"], + ["rank + 1", "reserved SQL word"], + ]; + it.each(cases)("%s", (expr, message) => { + const res = parseMeasureExpression(expr); + expect(res.error).toBeDefined(); + expect(res.error!.message).toContain(message); + }); + }); + + it("reports error positions", () => { + const res = parseMeasureExpression("revenue - 'oops'"); + expect(res.error).toBeDefined(); + expect(res.error!.position).toBe(10); + }); + + it("rejects deeply nested expressions", () => { + const expr = "(".repeat(100) + "a" + ")".repeat(100); + const res = parseMeasureExpression(expr); + expect(res.error).toBeDefined(); + expect(res.error!.message).toContain("deeply nested"); + }); + + // Mirrors the server parser, which fails flat chains of more than 64 + // binary operators with "too deeply nested". + it("limits total expression complexity like the server", () => { + const ok = Array.from({ length: 65 }, (_, i) => `a${i}`).join("+"); + expect(parseMeasureExpression(ok).error).toBeUndefined(); + const tooLong = Array.from({ length: 66 }, (_, i) => `a${i}`).join("+"); + expect(parseMeasureExpression(tooLong).error?.message).toContain( + "deeply nested", + ); + }); + + it("quotes measure refs that need it", () => { + expect(formatMeasureRef("revenue")).toBe("revenue"); + expect(formatMeasureRef("rank")).toBe('"rank"'); + expect(formatMeasureRef("true")).toBe('"true"'); + expect(formatMeasureRef("total volume")).toBe('"total volume"'); + expect(formatMeasureRef('weird"name')).toBe('"weird""name"'); + }); + + it("rejects expressions over the maximum length", () => { + const expr = "a+".repeat(MAX_EPHEMERAL_EXPRESSION_LENGTH / 2) + "a"; + const res = parseMeasureExpression(expr); + expect(res.error).toBeDefined(); + expect(res.error!.message).toContain("maximum length"); + }); + + // The function allowlist must stay in sync with the server's + // measureExpressionFuncs in runtime/metricsview/measure_expression.go. + it("matches the server function allowlist", () => { + expect(Object.keys(EPHEMERAL_MEASURE_FUNCTIONS).sort()).toEqual([ + "abs", + "ceil", + "coalesce", + "exp", + "floor", + "greatest", + "least", + "ln", + "nullif", + "power", + "round", + "sqrt", + ]); + }); +}); diff --git a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts new file mode 100644 index 000000000000..34cab49a4adb --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts @@ -0,0 +1,613 @@ +/** + * Client-side parser for ephemeral measure expressions. + * + * Mirrors the server-side grammar in `runtime/metricsview/measure_expression.go` + * (which is the authority): references to existing measure names, numeric + * literals, NULL, unary minus, the binary operators + - * / %, parentheses, + * and an allowlist of scalar functions. Everything else is rejected. + * + * The client parse exists for inline validation (position-aware errors), + * autocomplete (extracting referenced names) and URL param validation. + * One deliberate difference: SQL comments are rejected here, while the server + * strips them; the server never echoes input, so this is only stricter. + */ + +// Keep in sync with `measureExpressionFuncs` in runtime/metricsview/measure_expression.go. +// maxArgs of -1 means unbounded. +export const EPHEMERAL_MEASURE_FUNCTIONS: Record< + string, + { minArgs: number; maxArgs: number } +> = { + abs: { minArgs: 1, maxArgs: 1 }, + round: { minArgs: 1, maxArgs: 2 }, + floor: { minArgs: 1, maxArgs: 1 }, + ceil: { minArgs: 1, maxArgs: 1 }, + sqrt: { minArgs: 1, maxArgs: 1 }, + ln: { minArgs: 1, maxArgs: 1 }, + exp: { minArgs: 1, maxArgs: 1 }, + power: { minArgs: 2, maxArgs: 2 }, + coalesce: { minArgs: 2, maxArgs: -1 }, + nullif: { minArgs: 2, maxArgs: 2 }, + greatest: { minArgs: 2, maxArgs: -1 }, + least: { minArgs: 2, maxArgs: -1 }, +}; + +// Keep in sync with the limits in runtime/metricsview/measure_expression.go. +export const MAX_EPHEMERAL_EXPRESSION_LENGTH = 1024; +const MAX_DEPTH = 64; + +// SQL words the server-side parser treats as reserved: a measure with one of +// these names must be double-quoted in expressions. Generated by probing the +// server parser (see runtime/metricsview/measure_expression.go). +export const RESERVED_SQL_WORDS = new Set([ + "add", + "all", + "alter", + "analyze", + "and", + "array", + "as", + "asc", + "between", + "bigint", + "binary", + "blob", + "both", + "by", + "call", + "cascade", + "case", + "change", + "char", + "character", + "check", + "collate", + "column", + "constraint", + "continue", + "convert", + "create", + "cross", + "cume_dist", + "cursor", + "database", + "databases", + "day_hour", + "day_microsecond", + "day_minute", + "day_second", + "dec", + "decimal", + "default", + "delayed", + "delete", + "dense_rank", + "desc", + "describe", + "distinct", + "distinctrow", + "div", + "double", + "drop", + "dual", + "else", + "elseif", + "enclosed", + "escaped", + "except", + "exists", + "exit", + "explain", + "fetch", + "first_value", + "float", + "float4", + "float8", + "for", + "force", + "foreign", + "from", + "fulltext", + "generated", + "grant", + "group", + "groups", + "having", + "high_priority", + "hour_microsecond", + "hour_minute", + "hour_second", + "if", + "ignore", + "ilike", + "in", + "index", + "infile", + "inner", + "inout", + "insert", + "int", + "int1", + "int2", + "int3", + "int4", + "int8", + "integer", + "intersect", + "interval", + "into", + "is", + "iterate", + "join", + "key", + "keys", + "kill", + "lag", + "last_value", + "lead", + "leading", + "leave", + "left", + "like", + "limit", + "linear", + "lines", + "load", + "lock", + "long", + "longblob", + "longtext", + "low_priority", + "match", + "maxvalue", + "mediumblob", + "mediumint", + "mediumtext", + "middleint", + "minute_microsecond", + "minute_second", + "mod", + "natural", + "no_write_to_binlog", + "not", + "nth_value", + "ntile", + "numeric", + "of", + "on", + "optimize", + "option", + "optionally", + "or", + "order", + "out", + "outer", + "outfile", + "over", + "partition", + "percent_rank", + "precision", + "primary", + "procedure", + "range", + "rank", + "read", + "real", + "recursive", + "references", + "regexp", + "release", + "rename", + "repeat", + "replace", + "require", + "restrict", + "revoke", + "right", + "rlike", + "row", + "row_number", + "rows", + "schema", + "schemas", + "second_microsecond", + "select", + "set", + "show", + "smallint", + "spatial", + "sql", + "sql_big_result", + "sql_calc_found_rows", + "sql_small_result", + "sqlexception", + "sqlstate", + "sqlwarning", + "ssl", + "starting", + "stored", + "straight_join", + "table", + "tablesample", + "terminated", + "then", + "tinyblob", + "tinyint", + "tinytext", + "to", + "trailing", + "trigger", + "union", + "unique", + "unlock", + "unsigned", + "update", + "usage", + "use", + "using", + "values", + "varbinary", + "varchar", + "varcharacter", + "varying", + "virtual", + "when", + "where", + "while", + "window", + "with", + "write", + "xor", + "year_month", + "zerofill", +]); + +// Bare true/false parse as numeric literals server-side, and NULL as the null +// literal; measures with these names must also be quoted. +const LITERAL_WORDS = new Set(["true", "false", "null"]); + +/** + * Formats a measure name for insertion into an expression, double-quoting it + * when a bare reference would not survive the server-side SQL parser. + */ +export function formatMeasureRef(name: string): string { + const lower = name.toLowerCase(); + if ( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && + !RESERVED_SQL_WORDS.has(lower) && + !LITERAL_WORDS.has(lower) + ) { + return name; + } + return `"${name.replace(/"/g, '""')}"`; +} + +export type MeasureExpressionError = { + message: string; + // 0-based character offset into the expression where the error was detected. + position: number; +}; + +export type MeasureExpressionParseResult = { + // Referenced identifiers, deduplicated in order of first appearance. + refs: string[]; + error?: MeasureExpressionError; +}; + +type Token = { + kind: + | "ident" + | "quoted-ident" + | "number" + | "op" + | "lparen" + | "rparen" + | "comma" + | "null" + | "eof"; + text: string; + pos: number; +}; + +const IDENT_START = /[A-Za-z_]/; +const IDENT_CHAR = /[A-Za-z0-9_]/; + +class ParseError extends Error { + position: number; + constructor(message: string, position: number) { + super(message); + this.position = position; + } +} + +function tokenize(input: string): Token[] { + const tokens: Token[] = []; + let i = 0; + while (i < input.length) { + const ch = input[i]; + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + i++; + continue; + } + if (ch === "(") { + tokens.push({ kind: "lparen", text: ch, pos: i }); + i++; + continue; + } + if (ch === ")") { + tokens.push({ kind: "rparen", text: ch, pos: i }); + i++; + continue; + } + if (ch === ",") { + tokens.push({ kind: "comma", text: ch, pos: i }); + i++; + continue; + } + if (ch === "-" && input[i + 1] === "-") { + // The server-side SQL parser treats `--` as a comment start. + throw new ParseError( + `comments are not allowed in the expression (found "--"); use parentheses for double negation, e.g. "a - (-b)"`, + i, + ); + } + if ("+-*/%".includes(ch)) { + tokens.push({ kind: "op", text: ch, pos: i }); + i++; + continue; + } + if (ch === '"') { + // Double-quoted identifier; "" escapes a quote, matching ANSI SQL. + const start = i; + i++; + let name = ""; + let closed = false; + while (i < input.length) { + if (input[i] === '"') { + if (input[i + 1] === '"') { + name += '"'; + i += 2; + continue; + } + closed = true; + i++; + break; + } + name += input[i]; + i++; + } + if (!closed) { + throw new ParseError("unterminated quoted identifier", start); + } + if (name === "") { + throw new ParseError("empty quoted identifier", start); + } + tokens.push({ kind: "quoted-ident", text: name, pos: start }); + continue; + } + if (ch === "'") { + throw new ParseError( + "string literals are not allowed in the expression", + i, + ); + } + if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(input[i + 1] ?? ""))) { + const start = i; + const match = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec( + input.slice(i), + ); + if (!match) { + throw new ParseError(`invalid number`, start); + } + tokens.push({ kind: "number", text: match[0], pos: start }); + i += match[0].length; + continue; + } + if (IDENT_START.test(ch)) { + const start = i; + let name = ""; + while (i < input.length && IDENT_CHAR.test(input[i])) { + name += input[i]; + i++; + } + const lower = name.toLowerCase(); + if (LITERAL_WORDS.has(lower)) { + // Parsed as literals by the server-side SQL parser. + tokens.push({ kind: "null", text: name, pos: start }); + } else if (RESERVED_SQL_WORDS.has(lower)) { + throw new ParseError( + `"${name}" is a reserved SQL word; wrap it in double quotes to reference a measure with this name`, + start, + ); + } else { + tokens.push({ kind: "ident", text: name, pos: start }); + } + continue; + } + throw new ParseError(`unexpected character "${ch}"`, i); + } + tokens.push({ kind: "eof", text: "", pos: input.length }); + return tokens; +} + +class Parser { + private tokens: Token[]; + private index = 0; + private complexity = 0; + refs: string[] = []; + + constructor(tokens: Token[]) { + this.tokens = tokens; + } + + // The server parser builds a left-nested tree, so every binary operator adds + // a nesting level toward its depth limit; mirror that with a combined budget. + private spendComplexity(pos: number): void { + this.complexity++; + if (this.complexity > MAX_DEPTH) { + throw new ParseError("expression is too deeply nested", pos); + } + } + + private peek(): Token { + return this.tokens[this.index]; + } + + private next(): Token { + return this.tokens[this.index++]; + } + + parse(): void { + this.parseAdditive(0); + const tok = this.peek(); + if (tok.kind !== "eof") { + throw new ParseError(`unexpected "${tok.text}"`, tok.pos); + } + } + + private parseAdditive(depth: number): void { + this.parseMultiplicative(depth); + while (this.peek().kind === "op" && "+-".includes(this.peek().text)) { + this.spendComplexity(this.next().pos); + this.parseMultiplicative(depth); + } + } + + private parseMultiplicative(depth: number): void { + this.parseUnary(depth); + while (this.peek().kind === "op" && "*/%".includes(this.peek().text)) { + this.spendComplexity(this.next().pos); + this.parseUnary(depth); + } + } + + private parseUnary(depth: number): void { + if (depth > MAX_DEPTH) { + throw new ParseError("expression is too deeply nested", this.peek().pos); + } + if (this.peek().kind === "op" && this.peek().text === "-") { + this.spendComplexity(this.peek().pos); + this.next(); + this.parseUnary(depth + 1); + return; + } + this.parsePrimary(depth); + } + + private parsePrimary(depth: number): void { + const tok = this.next(); + switch (tok.kind) { + case "number": + case "null": + return; + case "quoted-ident": + this.addRef(tok.text); + return; + case "ident": { + if (this.peek().kind === "lparen") { + this.parseFunctionCall(tok, depth); + return; + } + this.addRef(tok.text); + return; + } + case "lparen": { + this.parseAdditive(depth + 1); + const close = this.next(); + if (close.kind !== "rparen") { + throw new ParseError("expected closing parenthesis", close.pos); + } + return; + } + case "eof": + throw new ParseError("unexpected end of expression", tok.pos); + default: + throw new ParseError(`unexpected "${tok.text}"`, tok.pos); + } + } + + private parseFunctionCall(nameTok: Token, depth: number): void { + const fnName = nameTok.text.toLowerCase(); + const spec = EPHEMERAL_MEASURE_FUNCTIONS[fnName]; + if (!spec) { + throw new ParseError( + `unsupported function "${nameTok.text}"`, + nameTok.pos, + ); + } + this.next(); // consume the lparen + let argCount = 0; + if (this.peek().kind !== "rparen") { + for (;;) { + this.parseAdditive(depth + 1); + argCount++; + if (this.peek().kind === "comma") { + this.next(); + continue; + } + break; + } + } + const close = this.next(); + if (close.kind !== "rparen") { + throw new ParseError("expected closing parenthesis", close.pos); + } + if ( + argCount < spec.minArgs || + (spec.maxArgs >= 0 && argCount > spec.maxArgs) + ) { + throw new ParseError( + `function "${nameTok.text}" does not accept ${argCount} argument(s)`, + nameTok.pos, + ); + } + } + + private addRef(name: string): void { + if (!this.refs.includes(name)) { + this.refs.push(name); + } + } +} + +/** + * Parses and validates an ephemeral measure expression. + * Returns the referenced identifiers, or a position-aware error. + * Note this validates the grammar only; callers must separately check that + * each ref is an existing measure name. + */ +export function parseMeasureExpression( + expression: string, +): MeasureExpressionParseResult { + if (expression.trim() === "") { + return { refs: [], error: { message: "expression is empty", position: 0 } }; + } + if (expression.length > MAX_EPHEMERAL_EXPRESSION_LENGTH) { + return { + refs: [], + error: { + message: `expression exceeds the maximum length of ${MAX_EPHEMERAL_EXPRESSION_LENGTH} characters`, + position: MAX_EPHEMERAL_EXPRESSION_LENGTH, + }, + }; + } + try { + const parser = new Parser(tokenize(expression)); + parser.parse(); + if (parser.refs.length === 0) { + return { + refs: [], + error: { + message: "expression must reference at least one measure", + position: 0, + }, + }; + } + return { refs: parser.refs }; + } catch (e) { + if (e instanceof ParseError) { + return { refs: [], error: { message: e.message, position: e.position } }; + } + throw e; + } +} diff --git a/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts b/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts new file mode 100644 index 000000000000..69baab353076 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts @@ -0,0 +1,116 @@ +import type { + MetricsViewSpecMeasure, + V1MetricsViewAggregationMeasure, +} from "@rilldata/web-common/runtime-client"; +import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; +import type { EphemeralMeasureDef } from "./types"; + +/** + * Builds a synthetic metrics view spec measure for an ephemeral measure so UI + * code (labels, formatters, tooltips, selectors) can treat it like any other + * measure. + */ +export function ephemeralMeasureToSpecMeasure( + def: EphemeralMeasureDef, +): MetricsViewSpecMeasure { + return { + name: def.name, + displayName: def.displayName, + expression: def.expression, + // Surfaces the calculation in description-driven tooltips. + description: def.expression, + formatPreset: def.formatPreset ?? (FormatPreset.HUMANIZE as string), + }; +} + +/** + * Appends synthetic spec measures for the given ephemeral measure + * definitions to a list of spec measures. + */ +export function appendEphemeralSpecMeasures( + measures: MetricsViewSpecMeasure[], + defs: EphemeralMeasureDef[] | undefined, +): MetricsViewSpecMeasure[] { + if (!defs?.length) return measures; + return [...measures, ...defs.map(ephemeralMeasureToSpecMeasure)]; +} + +/** + * Attaches the `expression` compute to request measures whose name matches a + * ephemeral measure definition. Leaves other measures untouched. + */ +export function mapEphemeralMeasuresForRequest( + measures: V1MetricsViewAggregationMeasure[], + defs: EphemeralMeasureDef[] | undefined, +): V1MetricsViewAggregationMeasure[] { + if (!defs?.length) return measures; + const byName = new Map(defs.map((def) => [def.name, def])); + return measures.map((measure) => { + const def = measure.name ? byName.get(measure.name) : undefined; + // Never attach an expression to a measure that already carries a compute + // (e.g. a comparison accessor whose alias happens to match a definition). + const hasCompute = + measure.expression || + measure.count || + measure.countDistinct || + measure.comparisonValue || + measure.comparisonDelta || + measure.comparisonRatio || + measure.percentOfTotal || + measure.uri || + measure.comparisonTime; + if (!def || hasCompute) return measure; + return { + ...measure, + expression: { + expression: def.expression, + displayName: def.displayName, + }, + }; + }); +} + +/** + * Splits measure names for the time series API: names of spec measures go in + * `measureNames`, ephemeral measures go in `measures` with their expression. + */ +export function splitTimeSeriesMeasures( + names: string[], + defs: EphemeralMeasureDef[] | undefined, +): { + measureNames: string[]; + ephemeralMeasures: V1MetricsViewAggregationMeasure[] | undefined; +} { + if (!defs?.length) + return { measureNames: names, ephemeralMeasures: undefined }; + const byName = new Map(defs.map((def) => [def.name, def])); + const measureNames: string[] = []; + const measures: V1MetricsViewAggregationMeasure[] = []; + for (const name of names) { + const def = byName.get(name); + if (def) { + measures.push({ + name: def.name, + expression: { + expression: def.expression, + displayName: def.displayName, + }, + }); + } else { + measureNames.push(name); + } + } + return { + measureNames, + ephemeralMeasures: measures.length ? measures : undefined, + }; +} + +/** + * Returns the set of ephemeral measure names, for quick membership checks. + */ +export function ephemeralMeasureNameSet( + defs: EphemeralMeasureDef[] | undefined, +): Set { + return new Set(defs?.map((def) => def.name) ?? []); +} diff --git a/web-common/src/features/dashboards/ephemeral-measures/types.ts b/web-common/src/features/dashboards/ephemeral-measures/types.ts new file mode 100644 index 000000000000..34ee2dae76d2 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/types.ts @@ -0,0 +1,18 @@ +// An ad-hoc "ephemeral measure" defined by the user for an explore dashboard, +// derived from existing metrics view measures via an arithmetic expression +// (e.g. Profit = revenue - cost). It is computed server-side via the metrics +// APIs' `expression` measure compute, which restricts expressions to +// references to existing measures, numeric literals, basic arithmetic and a +// small allowlist of functions. Definitions live on the explore state, are +// shared by all views (leaderboards, charts, pivot, ...) and are encoded in +// the `ephemeral` URL param so shared links reproduce them. +export type EphemeralMeasureDef = { + // Query alias, e.g. "profit". Must not collide with metrics view field names. + name: string; + // Display name shown in the UI, e.g. "Profit". + displayName: string; + // Arithmetic expression over existing measure names, e.g. "revenue - cost". + expression: string; + // Optional FormatPreset for rendering values; defaults to humanize. + formatPreset?: string; +}; diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts new file mode 100644 index 000000000000..e6d7b3d180ea --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts @@ -0,0 +1,218 @@ +import { PivotChipType } from "@rilldata/web-common/features/dashboards/pivot/types"; +import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; +import { + AD_BIDS_EXPLORE_INIT, + AD_BIDS_EXPLORE_NAME, + AD_BIDS_METRICS_3_MEASURES_DIMENSIONS, + AD_BIDS_METRICS_INIT, + AD_BIDS_TIME_RANGE_SUMMARY, +} from "@rilldata/web-common/features/dashboards/stores/test-data/data"; +import { getInitExploreStateForTest } from "@rilldata/web-common/features/dashboards/stores/test-data/helpers"; +import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/getDefaultExplorePreset"; +import { + applyURLToExploreState, + getCleanMetricsExploreForAssertion, +} from "@rilldata/web-common/features/dashboards/url-state/url-state-variations.spec"; +import { DashboardState_ActivePage } from "@rilldata/web-common/proto/gen/rill/ui/v1/dashboard_pb"; +import { get } from "svelte/store"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + fromEphemeralMeasuresParam, + toEphemeralMeasuresParam, +} from "./url-param"; + +describe("ephemeral url param", () => { + describe("round trips", () => { + const cases = [ + [{ name: "profit", displayName: "Profit", expression: "revenue - cost" }], + [ + { + name: "profit", + displayName: "Profit", + expression: "revenue - cost", + formatPreset: "currency_usd", + }, + { + name: "arpu", + displayName: "ARPU (µ)", + expression: "round(revenue / users, 2)", + }, + ], + // Hostile characters in display names and expressions. + [ + { + name: "weird", + displayName: 'a:b;c,d%e+f"g', + expression: '"total volume" % 100 + 0.5', + }, + ], + ]; + it.each(cases)("%j", (...defs) => { + const param = toEphemeralMeasuresParam(defs); + const { ephemeralMeasures, invalidEntries } = + fromEphemeralMeasuresParam(param); + expect(invalidEntries).toEqual([]); + expect(ephemeralMeasures).toEqual(defs); + }); + }); + + it("returns an empty param for no definitions", () => { + expect(toEphemeralMeasuresParam(undefined)).toBe(""); + expect(toEphemeralMeasuresParam([])).toBe(""); + }); + + it("drops malformed entries and keeps the rest", () => { + const { ephemeralMeasures, invalidEntries } = fromEphemeralMeasuresParam( + "profit:Profit:revenue;bad-entry;other:Other:cost", + ); + expect(ephemeralMeasures.map((d) => d.name)).toEqual(["profit", "other"]); + expect(invalidEntries).toEqual(["bad-entry"]); + }); + + it("drops duplicate names", () => { + const { ephemeralMeasures, invalidEntries } = fromEphemeralMeasuresParam( + "profit:Profit:revenue;profit:Other:cost", + ); + expect(ephemeralMeasures.map((d) => d.name)).toEqual(["profit"]); + expect(invalidEntries).toEqual(["profit:Other:cost"]); + }); +}); + +describe("ephemeral measures URL state integration", () => { + beforeEach(() => { + metricsExplorerStore.remove(AD_BIDS_EXPLORE_NAME); + metricsExplorerStore.init( + AD_BIDS_EXPLORE_NAME, + getInitExploreStateForTest( + AD_BIDS_METRICS_3_MEASURES_DIMENSIONS, + AD_BIDS_EXPLORE_INIT, + AD_BIDS_TIME_RANGE_SUMMARY, + ), + ); + }); + + function applyUrl(url: string) { + const defaultExplorePreset = getDefaultExplorePreset( + AD_BIDS_EXPLORE_INIT, + AD_BIDS_METRICS_INIT, + AD_BIDS_TIME_RANGE_SUMMARY.timeRangeSummary, + ); + return applyURLToExploreState( + new URL(url), + AD_BIDS_EXPLORE_INIT, + defaultExplorePreset, + ); + } + + it("restores ephemeral measures and their pivot columns from the URL", () => { + const errors = applyUrl( + "http://localhost/explore/AdBids_explore?view=pivot&rows=publisher&cols=impressions,profit&ephemeral=profit:Profit:impressions*2", + ); + expect(errors).toEqual([]); + + const state = getCleanMetricsExploreForAssertion(); + expect(state.ephemeralMeasures).toEqual([ + { name: "profit", displayName: "Profit", expression: "impressions*2" }, + ]); + expect(state.pivot?.columns).toEqual([ + { id: "impressions", title: "impressions", type: PivotChipType.Measure }, + { id: "profit", title: "Profit", type: PivotChipType.Measure }, + ]); + }); + + it("drops definitions referencing unknown measures, and their columns", () => { + const errors = applyUrl( + "http://localhost/explore/AdBids_explore?view=pivot&cols=impressions,profit&ephemeral=profit:Profit:unknown*2", + ); + expect(errors.map((e) => e.message)).toEqual([ + `Selected calculated measure: "profit ("unknown" is not a measure in this dashboard)" is not valid.`, + `Selected pivot column: "profit" is not valid.`, + ]); + + const state = getCleanMetricsExploreForAssertion(); + expect(state.ephemeralMeasures).toBeUndefined(); + expect(state.pivot?.columns).toEqual([ + { id: "impressions", title: "impressions", type: PivotChipType.Measure }, + ]); + }); + + it("drops definitions whose name collides with a metrics view field", () => { + const errors = applyUrl( + "http://localhost/explore/AdBids_explore?view=pivot&cols=bid_price&ephemeral=bid_price:Custom:impressions*2", + ); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain("already used by another field"); + + const state = getCleanMetricsExploreForAssertion(); + expect(state.ephemeralMeasures).toBeUndefined(); + }); + + it("accepts conditional formatting on an ephemeral measure", () => { + const errors = applyUrl( + "http://localhost/explore/AdBids_explore?view=pivot&cols=profit&ephemeral=profit:Profit:impressions*2&format=profit:heatmap:greens", + ); + expect(errors).toEqual([]); + + const state = getCleanMetricsExploreForAssertion(); + expect(state.pivot?.measureFormatting).toEqual({ + profit: { mode: "heatmap", scheme: "greens" }, + }); + }); + + it("survives a state → URL → state round trip", async () => { + const { convertPartialExploreStateToUrlParams } = await import( + "@rilldata/web-common/features/dashboards/url-state/convert-partial-explore-state-to-url-params" + ); + const { convertURLSearchParamsToExploreState } = await import( + "@rilldata/web-common/features/dashboards/url-state/convertURLSearchParamsToExploreState" + ); + const { AD_BIDS_METRICS_VIEW } = await import( + "@rilldata/web-common/features/dashboards/stores/test-data/data" + ); + + // Switch to the pivot view first so the add also places a pivot column. + get(metricsExplorerStore).entities[AD_BIDS_EXPLORE_NAME].activePage = + DashboardState_ActivePage.PIVOT; + metricsExplorerStore.addEphemeralMeasure(AD_BIDS_EXPLORE_NAME, { + name: "profit", + displayName: "Profit (net)", + expression: 'impressions * 2 + "bid_price"', + }); + const exploreState = + get(metricsExplorerStore).entities[AD_BIDS_EXPLORE_NAME]; + + const urlParams = convertPartialExploreStateToUrlParams( + AD_BIDS_EXPLORE_INIT, + AD_BIDS_METRICS_VIEW, + exploreState, + undefined, + ); + // Serialize + reparse, as a browser would. + const reparsed = new URLSearchParams(urlParams.toString()); + // An empty `f=` param errors in the filter parser; in the app it is + // stripped by cleanUrlParams, which is not part of this round trip. + if (reparsed.get("f") === "") reparsed.delete("f"); + const { partialExploreState, errors } = + convertURLSearchParamsToExploreState( + reparsed, + AD_BIDS_METRICS_VIEW, + AD_BIDS_EXPLORE_INIT, + {}, + ); + expect(errors).toEqual([]); + expect(partialExploreState.ephemeralMeasures).toEqual([ + { + name: "profit", + displayName: "Profit (net)", + expression: 'impressions * 2 + "bid_price"', + }, + ]); + expect( + partialExploreState.pivot?.columns.find((c) => c.id === "profit"), + ).toEqual({ + id: "profit", + title: "Profit (net)", + type: PivotChipType.Measure, + }); + }); +}); diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-param.ts b/web-common/src/features/dashboards/ephemeral-measures/url-param.ts new file mode 100644 index 000000000000..295f3a84a445 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/url-param.ts @@ -0,0 +1,78 @@ +import type { EphemeralMeasureDef } from "./types"; +import { MAX_EPHEMERAL_MEASURES } from "./validation"; + +/** + * Compact serialization of ephemeral measure definitions for the stateful URL + * (and the explore preset, which stores the same string). + * + * Grammar: + * param := entry (";" entry)* + * entry := name ":" enc(displayName) ":" enc(expression) [":" formatPreset] + * + * `enc` is encodeURIComponent applied per field: it encodes ":" and ";" so + * splitting is unambiguous. `name` is a validated slug and needs no encoding. + * + * Example: `profit:Profit:revenue%20-%20cost;arpu:ARPU:revenue%20%2F%20users:currency_usd` + */ + +export function toEphemeralMeasuresParam( + defs: EphemeralMeasureDef[] | undefined, +): string { + if (!defs?.length) return ""; + return defs + .map((def) => { + const parts = [ + def.name, + encodeURIComponent(def.displayName), + encodeURIComponent(def.expression), + ]; + if (def.formatPreset) parts.push(def.formatPreset); + return parts.join(":"); + }) + .join(";"); +} + +export function fromEphemeralMeasuresParam(param: string): { + ephemeralMeasures: EphemeralMeasureDef[]; + invalidEntries: string[]; +} { + const ephemeralMeasures: EphemeralMeasureDef[] = []; + const invalidEntries: string[] = []; + const seen = new Set(); + + for (const entry of param.split(";")) { + if (!entry) continue; + const parts = entry.split(":"); + if (parts.length < 3 || parts.length > 4) { + invalidEntries.push(entry); + continue; + } + const [name, encDisplayName, encExpression, formatPreset] = parts; + let displayName: string; + let expression: string; + try { + displayName = decodeURIComponent(encDisplayName); + expression = decodeURIComponent(encExpression); + } catch { + invalidEntries.push(entry); + continue; + } + if (!name || !displayName || !expression || seen.has(name)) { + invalidEntries.push(entry); + continue; + } + if (ephemeralMeasures.length >= MAX_EPHEMERAL_MEASURES) { + invalidEntries.push(entry); + continue; + } + seen.add(name); + ephemeralMeasures.push({ + name, + displayName, + expression, + ...(formatPreset ? { formatPreset } : {}), + }); + } + + return { ephemeralMeasures, invalidEntries }; +} diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-state.ts b/web-common/src/features/dashboards/ephemeral-measures/url-state.ts new file mode 100644 index 000000000000..6ec1f11228f8 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/url-state.ts @@ -0,0 +1,80 @@ +import type { + MetricsViewSpecDimension, + MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client"; +import { ephemeralMeasureToSpecMeasure } from "./measure-mapping"; +import { fromEphemeralMeasuresParam } from "./url-param"; +import type { EphemeralMeasureDef } from "./types"; +import { + isReferenceableMeasure, + validateEphemeralMeasureDef, +} from "./validation"; + +/** + * Validates ephemeral measure definitions against the metrics view / explore + * field maps used during URL state conversion. Returns the valid definitions + * and human-readable labels for the dropped ones. + */ +export function validateEphemeralDefsAgainstSpec( + defs: EphemeralMeasureDef[], + measures: Map, + dimensions: Map, +): { valid: EphemeralMeasureDef[]; invalidEntries: string[] } { + const knownMeasureNames = new Set( + [...measures.values()] + .filter(isReferenceableMeasure) + .map((m) => m.name as string), + ); + const reservedNames = new Set([...measures.keys(), ...dimensions.keys()]); + const valid: EphemeralMeasureDef[] = []; + const invalidEntries: string[] = []; + for (const def of defs) { + const error = validateEphemeralMeasureDef( + def, + knownMeasureNames, + reservedNames, + ); + if (error) { + invalidEntries.push(`${def.name} (${error})`); + } else { + valid.push(def); + } + } + return { valid, invalidEntries }; +} + +/** + * Parses and validates a `ephemeral` URL param value. + */ +export function parseAndValidateEphemeralParam( + param: string, + measures: Map, + dimensions: Map, +): { valid: EphemeralMeasureDef[]; invalidEntries: string[] } { + const { ephemeralMeasures, invalidEntries } = + fromEphemeralMeasuresParam(param); + const res = validateEphemeralDefsAgainstSpec( + ephemeralMeasures, + measures, + dimensions, + ); + return { + valid: res.valid, + invalidEntries: [...invalidEntries, ...res.invalidEntries], + }; +} + +/** + * Adds synthetic spec measures for the definitions into the measures map used + * during URL state conversion, so every existing name validation (visible + * measures, leaderboards, sort, pivot columns, formatting, ...) accepts + * ephemeral measure names without per-param special cases. + */ +export function injectEphemeralMeasuresIntoMap( + measures: Map, + defs: EphemeralMeasureDef[], +): void { + for (const def of defs) { + measures.set(def.name, ephemeralMeasureToSpecMeasure(def)); + } +} diff --git a/web-common/src/features/dashboards/ephemeral-measures/validation.ts b/web-common/src/features/dashboards/ephemeral-measures/validation.ts new file mode 100644 index 000000000000..0d3d41a93f8d --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/validation.ts @@ -0,0 +1,99 @@ +import { + MetricsViewSpecMeasureType, + type MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client"; +import { ComparisonModifierSuffixRegex } from "../pivot/types"; +import { parseMeasureExpression } from "./expression-parser"; +import type { EphemeralMeasureDef } from "./types"; + +// Maximum number of ephemeral measures per pivot. +export const MAX_EPHEMERAL_MEASURES = 10; + +export const EPHEMERAL_MEASURE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]*$/; + +// Accessor suffixes appended to measure names when building requests: +// the pivot's `__delta_abs`-style suffixes (ComparisonModifierSuffixRegex) and +// the leaderboard/dimension-table `_prev`-style suffixes below. An ephemeral +// measure ending in one of these would collide with another measure's +// comparison accessor. +const RESERVED_NAME_SUFFIXES = [ + "_prev", + "_delta", + "_delta_perc", + "_percent_of_total", +]; + +/** + * Returns whether a measure may be referenced from an ephemeral measure's + * expression. Window measures, measures with required dimensions and + * time-comparison measures are excluded: the same views that filter those + * spec measures out of their requests (totals, big numbers, ...) cannot + * filter an ephemeral wrapper, so allowing the reference would produce + * permanent query errors. + */ +export function isReferenceableMeasure( + measure: MetricsViewSpecMeasure, +): boolean { + return ( + !measure.window && + !measure.requiredDimensions?.length && + measure.type !== MetricsViewSpecMeasureType.MEASURE_TYPE_TIME_COMPARISON + ); +} + +/** + * Validates an ephemeral measure's name (the query alias). + * `reservedNames` should contain all metrics view field names (measures, + * dimensions, the time dimension) plus the names of other ephemeral measures. + */ +export function validateEphemeralMeasureName( + name: string, + reservedNames: Set, +): string | undefined { + if (!EPHEMERAL_MEASURE_NAME_REGEX.test(name)) { + return "name must start with a letter and contain only letters, numbers and underscores"; + } + if ( + ComparisonModifierSuffixRegex.test(name) || + RESERVED_NAME_SUFFIXES.some((suffix) => name.endsWith(suffix)) + ) { + return "name must not end with a comparison suffix"; + } + if (name.includes("_rill_")) { + return 'name must not contain "_rill_"'; + } + if (reservedNames.has(name)) { + return `"${name}" is already used by another field`; + } + return undefined; +} + +/** + * Validates a full ephemeral measure definition. + * `knownMeasureNames` are the measure names an expression may reference + * (the metrics view's measures available in this explore; ephemeral measures + * cannot reference other ephemeral measures). + */ +export function validateEphemeralMeasureDef( + def: EphemeralMeasureDef, + knownMeasureNames: Set, + reservedNames: Set, +): string | undefined { + const nameError = validateEphemeralMeasureName(def.name, reservedNames); + if (nameError) return nameError; + + if (def.displayName.trim() === "") { + return "display name is required"; + } + + const parsed = parseMeasureExpression(def.expression); + if (parsed.error) { + return parsed.error.message; + } + for (const ref of parsed.refs) { + if (!knownMeasureNames.has(ref)) { + return `"${ref}" is not a measure in this dashboard`; + } + } + return undefined; +} diff --git a/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte b/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte index 41d86096e618..45b017695db0 100644 --- a/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte +++ b/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte @@ -1,6 +1,11 @@
- +
+ + +
{#if !collapsed}
diff --git a/web-common/src/features/dashboards/pivot/PivotHeader.svelte b/web-common/src/features/dashboards/pivot/PivotHeader.svelte index ddf5bbd52cdc..bfb84cee37dd 100644 --- a/web-common/src/features/dashboards/pivot/PivotHeader.svelte +++ b/web-common/src/features/dashboards/pivot/PivotHeader.svelte @@ -2,6 +2,7 @@ import Column from "@rilldata/web-common/components/icons/Column.svelte"; import Row from "@rilldata/web-common/components/icons/Row.svelte"; import { splitPivotChips } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils.ts"; + import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { slide } from "svelte/transition"; import DragList from "./DragList.svelte"; import PivotAutoArrangeZone from "./PivotAutoArrangeZone.svelte"; @@ -21,8 +22,16 @@ | ((measureName: string, fmt: PivotMeasureFormatting | null) => void) | undefined = undefined; export let lowerIsBetterMap: Record = {}; + export let ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined; + export let onEditEphemeralMeasure: ((id: string) => void) | undefined = + undefined; + export let onDeleteEphemeralMeasure: ((id: string) => void) | undefined = + undefined; $: ({ rows, columns, tableMode, measureFormatting } = pivotState); + $: ephemeralMeasureNames = new Set( + ephemeralMeasures?.map((def) => def.name) ?? [], + ); $: splitColumns = splitPivotChips(columns); $: fullColumns = splitColumns.dimension.concat(splitColumns.measure); $: isFlat = tableMode === "flat"; @@ -78,6 +87,9 @@ {measureFormatting} {setMeasureFormatting} {lowerIsBetterMap} + {ephemeralMeasureNames} + {onEditEphemeralMeasure} + {onDeleteEphemeralMeasure} />
diff --git a/web-common/src/features/dashboards/pivot/PivotSidebar.svelte b/web-common/src/features/dashboards/pivot/PivotSidebar.svelte index 27192e53afe8..b23b2659e609 100644 --- a/web-common/src/features/dashboards/pivot/PivotSidebar.svelte +++ b/web-common/src/features/dashboards/pivot/PivotSidebar.svelte @@ -17,6 +17,8 @@ PivotSidebarSection, PivotState, } from "web-common/src/features/dashboards/pivot/types.ts"; + import Add from "@rilldata/web-common/components/icons/Add.svelte"; + import { ephemeralMeasureDialog } from "../ephemeral-measures/dialog-store"; import PivotDrag from "./PivotDrag.svelte"; import PivotTagRow from "./PivotTagRow.svelte"; import { timePillActions, timePillSelectors } from "./time-pill-store"; @@ -213,7 +215,18 @@ title={MEASURES_ZONE} label={m.dashboard_measures()} items={filteredMeasures} - /> + > + + d.name), + ); + const measureNames = colMeasures.flatMap((m) => { const measureName = m.id; const group = [measureName]; - if (enableComparison) { + // Comparison columns are not supported for ephemeral measures: + // the comparison computes resolve their referenced measure against + // the metrics view spec, which has no entry for them. + if (enableComparison && !ephemeralMeasureNames.has(measureName)) { group.push( `${measureName}${COMPARISON_DELTA}`, `${measureName}${COMPARISON_PERCENT}`, @@ -139,10 +148,15 @@ export function getPivotConfig( measureNames, rowDimensionNames, colDimensionNames, - allMeasures: allMeasures({ - validMetricsView: metricsView, - validExplore: explore, - }), + // Ephemeral measures get a synthetic spec entry so column definitions + // (labels, formatters, tooltips) resolve them like any other measure. + allMeasures: appendEphemeralSpecMeasures( + allMeasures({ + validMetricsView: metricsView, + validExplore: explore, + }), + ephemeralMeasures, + ), allDimensions: allDimensions({ validMetricsView: metricsView, validExplore: explore, @@ -157,6 +171,7 @@ export function getPivotConfig( time, searchText, isFlat, + ephemeralMeasures, }; const currentKey = getPivotConfigKey(config); diff --git a/web-common/src/features/dashboards/pivot/pivot-export.ts b/web-common/src/features/dashboards/pivot/pivot-export.ts index c2f81b82822f..ac25827fc8fe 100644 --- a/web-common/src/features/dashboards/pivot/pivot-export.ts +++ b/web-common/src/features/dashboards/pivot/pivot-export.ts @@ -14,7 +14,7 @@ import { import { get } from "svelte/store"; import type { StateManagers } from "../state-managers/state-managers"; import { getPivotConfig } from "./pivot-data-config"; -import { prepareMeasureForComparison } from "./pivot-utils"; +import { prepareMeasuresForRequest } from "./pivot-utils"; import { COMPARISON_DELTA, COMPARISON_PERCENT, @@ -106,11 +106,15 @@ export function getPivotAggregationRequest({ isFlat: boolean; pivotState: PivotState; }): undefined | V1MetricsViewAggregationRequest { + const ephemeralMeasureNames = new Set( + exploreState.ephemeralMeasures?.map((def) => def.name) ?? [], + ); const measures = columns.measure.flatMap((m) => { const measureName = m.id; const group = [{ name: measureName }]; - if (enableComparison) { + // Comparison columns are not supported for ephemeral measures. + if (enableComparison && !ephemeralMeasureNames.has(measureName)) { group.push( { name: `${measureName}${COMPARISON_DELTA}` }, { name: `${measureName}${COMPARISON_PERCENT}` }, @@ -190,9 +194,10 @@ export function getPivotAggregationRequest({ metricsView: metricsViewName, timeRange, comparisonTimeRange: comparisonTime, - measures: enableComparison - ? prepareMeasureForComparison(measures) - : measures, + measures: prepareMeasuresForRequest( + measures, + exploreState.ephemeralMeasures, + ), dimensions: allDimensions, where: sanitiseExpression( mergeDimensionAndMeasureFilters( diff --git a/web-common/src/features/dashboards/pivot/pivot-queries.ts b/web-common/src/features/dashboards/pivot/pivot-queries.ts index 478341c3f6f1..165ca92c27c6 100644 --- a/web-common/src/features/dashboards/pivot/pivot-queries.ts +++ b/web-common/src/features/dashboards/pivot/pivot-queries.ts @@ -26,7 +26,7 @@ import { getTimeGrainFromDimension, getUriMeasuresForDimensions, isTimeDimension, - prepareMeasureForComparison, + prepareMeasuresForRequest, } from "./pivot-utils"; import { COMPARISON_DELTA, @@ -79,7 +79,10 @@ export function createPivotAggregationRowQuery( ctx.runtimeClient, { metricsView: metricsViewName, - measures: prepareMeasureForComparison(measures), + measures: prepareMeasuresForRequest( + measures, + config.ephemeralMeasures, + ), dimensions, where: sanitiseExpression(whereFilter, undefined), timeRange: { diff --git a/web-common/src/features/dashboards/pivot/pivot-utils.ts b/web-common/src/features/dashboards/pivot/pivot-utils.ts index 3c790e926cb4..7be4af5a9639 100644 --- a/web-common/src/features/dashboards/pivot/pivot-utils.ts +++ b/web-common/src/features/dashboards/pivot/pivot-utils.ts @@ -30,6 +30,8 @@ import { getURIRequestMeasure } from "@rilldata/web-common/features/dashboards/d import { SHOW_MORE_BUTTON } from "./pivot-constants"; import { getColumnFiltersForPage } from "./pivot-infinite-scroll"; import { mergeFilters } from "./pivot-merge-filters"; +import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; +import { mapEphemeralMeasuresForRequest } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { COMPARISON_DELTA, COMPARISON_PERCENT, @@ -95,8 +97,11 @@ export function getPivotConfigKey(config: PivotDataStoreConfig) { const dimsAndMeasures = rowDimensionNames .concat(measureNames, colDimensionNames) .join("_"); + // Ephemeral measure definitions are part of the key so editing an + // expression (without renaming) refetches instead of serving cached data. + const ephemeralMeasuresKey = JSON.stringify(config.ephemeralMeasures ?? []); - return `${dimsAndMeasures}_${timeKey}_${sortingKey}_${tableModeKey}_${filterKey}_${enableComparison}_${comparisonTimeKey}_${showTotalsColumn}_${showTotalsRow}_${rowLimit ?? "all"}_${outermostRowLimit ?? "none"}`; + return `${dimsAndMeasures}_${timeKey}_${sortingKey}_${tableModeKey}_${filterKey}_${enableComparison}_${comparisonTimeKey}_${showTotalsColumn}_${showTotalsRow}_${rowLimit ?? "all"}_${outermostRowLimit ?? "none"}_${ephemeralMeasuresKey}`; } /** @@ -540,6 +545,21 @@ export function prepareMeasureForComparison( }); } +/** + * Maps plain measure names to request measures: comparison-suffixed names get + * their comparison compute, and ephemeral measure names get the `expression` + * compute carrying their definition. + */ +export function prepareMeasuresForRequest( + measures: V1MetricsViewAggregationMeasure[], + ephemeralMeasures: EphemeralMeasureDef[] | undefined, +): V1MetricsViewAggregationMeasure[] { + return mapEphemeralMeasuresForRequest( + prepareMeasureForComparison(measures), + ephemeralMeasures, + ); +} + export function canEnablePivotComparison( pivotState: PivotState, comparisonStart: string | Date | undefined, diff --git a/web-common/src/features/dashboards/pivot/types.ts b/web-common/src/features/dashboards/pivot/types.ts index b76d9ab7b497..fb7029602220 100644 --- a/web-common/src/features/dashboards/pivot/types.ts +++ b/web-common/src/features/dashboards/pivot/types.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { type TimeRangeString } from "@rilldata/web-common/lib/time/types"; import type { MetricsViewSpecDimension, @@ -155,6 +156,10 @@ export interface PivotDataStoreConfig { comparisonTime: TimeRangeString | undefined; searchText: string | undefined; isFlat: boolean; + // ephemeral measure definitions available to this pivot. + // Sourced from the explore state in explore dashboards, and from the + // component spec in canvas table/pivot components. + ephemeralMeasures?: EphemeralMeasureDef[]; } export interface PivotAxesData { diff --git a/web-common/src/features/dashboards/proto-state/fromProto.ts b/web-common/src/features/dashboards/proto-state/fromProto.ts index d3045c4ebd0b..a3feb26546d0 100644 --- a/web-common/src/features/dashboards/proto-state/fromProto.ts +++ b/web-common/src/features/dashboards/proto-state/fromProto.ts @@ -225,6 +225,15 @@ export function getDashboardStateFromProto( entity.leaderboardMeasureNames = dashboard.leaderboardMeasures; } + if (dashboard.ephemeralMeasures?.length) { + entity.ephemeralMeasures = dashboard.ephemeralMeasures.map((def) => ({ + name: def.name, + displayName: def.displayName, + expression: def.expression, + ...(def.formatPreset ? { formatPreset: def.formatPreset } : {}), + })); + } + if (dashboard.activePage === DashboardState_ActivePage.PIVOT) { entity.pivot = fromPivotProto(dashboard, metricsView); } else if (dashboard.activePage !== DashboardState_ActivePage.UNSPECIFIED) { @@ -382,6 +391,10 @@ function fromPivotProto( } }; + const ephemeralMeasuresMap = new Map( + (dashboard.ephemeralMeasures ?? []).map((def) => [def.name, def]), + ); + const measuresMap = getMapFromArray( metricsView.measures ?? [], (m) => m.name, @@ -390,7 +403,11 @@ function fromPivotProto( const mes = measuresMap.get(name); return { id: name, - title: mes?.displayName || mes?.name || "Unknown", + title: + mes?.displayName || + mes?.name || + ephemeralMeasuresMap.get(name)?.displayName || + "Unknown", type: PivotChipType.Measure, }; }; diff --git a/web-common/src/features/dashboards/proto-state/toProto.ts b/web-common/src/features/dashboards/proto-state/toProto.ts index 3faa4c2fd515..19ce75335008 100644 --- a/web-common/src/features/dashboards/proto-state/toProto.ts +++ b/web-common/src/features/dashboards/proto-state/toProto.ts @@ -78,6 +78,14 @@ export function getProtoFromDashboardState( if (!exploreState) return ""; const state: PartialMessage = {}; + if (exploreState.ephemeralMeasures?.length) { + state.ephemeralMeasures = exploreState.ephemeralMeasures.map((def) => ({ + name: def.name, + displayName: def.displayName, + expression: def.expression, + formatPreset: def.formatPreset ?? "", + })); + } if (exploreState.whereFilter) { state.where = toExpressionProto(exploreState.whereFilter); } diff --git a/web-common/src/features/dashboards/state-managers/loaders/most-recent-explore-state.ts b/web-common/src/features/dashboards/state-managers/loaders/most-recent-explore-state.ts index 8eec7a7c9077..d7633df86277 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/most-recent-explore-state.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/most-recent-explore-state.ts @@ -67,6 +67,7 @@ export function saveMostRecentPartialExploreState( { selectedTimezone: exploreState.selectedTimezone, + ephemeralMeasures: exploreState.ephemeralMeasures, visibleMeasures: exploreState.visibleMeasures, allMeasuresVisible: exploreState.allMeasuresVisible, visibleDimensions: exploreState.visibleDimensions, diff --git a/web-common/src/features/dashboards/state-managers/selectors/measures.ts b/web-common/src/features/dashboards/state-managers/selectors/measures.ts index a814106e1a66..c9bbdcdbdb66 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/measures.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/measures.ts @@ -1,3 +1,4 @@ +import { appendEphemeralSpecMeasures } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; import { MetricsViewSpecMeasureType, @@ -32,21 +33,26 @@ type AggregationMeasureRef = Pick< export const allMeasures = ({ validMetricsView, validExplore, -}: Pick< - DashboardDataSources, - "validMetricsView" | "validExplore" ->): MetricsViewSpecMeasure[] => { + dashboard, +}: Pick & + Partial< + Pick + >): MetricsViewSpecMeasure[] => { if (!validMetricsView?.measures || !validExplore?.measures) return []; - return ( - validMetricsView.measures - .filter((m) => validExplore.measures!.includes(m.name!)) - // Sort the filtered measures based on their order in validExplore.measures - .sort( - (a, b) => - validExplore.measures!.indexOf(a.name!) - - validExplore.measures!.indexOf(b.name!), - ) + const specMeasures = validMetricsView.measures + .filter((m) => validExplore.measures!.includes(m.name!)) + // Sort the filtered measures based on their order in validExplore.measures + .sort( + (a, b) => + validExplore.measures!.indexOf(a.name!) - + validExplore.measures!.indexOf(b.name!), + ); + // ephemeral measures behave like regular measures across the + // explore; they get synthetic spec entries so labels/formatting resolve. + return appendEphemeralSpecMeasures( + specMeasures, + dashboard?.ephemeralMeasures, ); }; @@ -57,8 +63,9 @@ export const visibleMeasures = ({ }: DashboardDataSources): MetricsViewSpecMeasure[] => { if (!validMetricsView?.measures || !validExplore?.measures) return []; + const all = allMeasures({ validMetricsView, validExplore, dashboard }); return dashboard.visibleMeasures - .map((mes) => validMetricsView.measures?.find((m) => m.name === mes)) + .map((mes) => all.find((m) => m.name === mes)) .filter(Boolean) as MetricsViewSpecMeasure[]; }; @@ -70,13 +77,11 @@ export const getMeasureByName = ( }; }; -export const measureLabel = ({ - validMetricsView, -}: DashboardDataSources): ((m: string) => string) => { +export const measureLabel = ( + dashData: DashboardDataSources, +): ((m: string) => string) => { return (measureName) => { - const measure = validMetricsView?.measures?.find( - (d) => d.name === measureName, - ); + const measure = allMeasures(dashData).find((d) => d.name === measureName); return measure?.displayName || measureName; }; }; @@ -142,7 +147,17 @@ export const filterOutSomeAdvancedMeasures = ( ) => { const measuresSeen = new Set(); + const ephemeralMeasureNames = new Set( + exploreState.ephemeralMeasures?.map((def) => def.name) ?? [], + ); + return measureNames.filter((measureName) => { + // ephemeral measures are simple aggregations; always supported. + if (ephemeralMeasureNames.has(measureName)) { + if (measuresSeen.has(measureName)) return false; + measuresSeen.add(measureName); + return true; + } const measureSpec = metricsViewSpec.measures?.find( (m) => m.name === measureName, ); @@ -181,19 +196,25 @@ export const filterOutSomeAdvancedAggregationMeasures = < ): T[] => { const measuresSeen = new Set(); + const ephemeralMeasureNames = new Set( + exploreState.ephemeralMeasures?.map((def) => def.name) ?? [], + ); + return measures.filter((measure) => { + const sourceMeasureName = + measure.comparisonDelta?.measure ?? + measure.comparisonValue?.measure ?? + measure.comparisonRatio?.measure ?? + measure.percentOfTotal?.measure ?? + measure.name ?? + ""; // Ephemeral expression measures are derived from measures already in the spec and have no spec // entry of their own, so there is no source measure to resolve or check for support. - const isEphemeralExpression = - !!measure.expression && typeof measure.expression === "object"; - if (!isEphemeralExpression) { - const sourceMeasureName = - measure.comparisonDelta?.measure ?? - measure.comparisonValue?.measure ?? - measure.comparisonRatio?.measure ?? - measure.percentOfTotal?.measure ?? - measure.name ?? - ""; + // The same applies to comparison measures derived from an ephemeral measure. + const isEphemeral = + (!!measure.expression && typeof measure.expression === "object") || + ephemeralMeasureNames.has(sourceMeasureName); + if (!isEphemeral) { const measureSpec = metricsViewSpec.measures?.find( (m) => m.name === sourceMeasureName, ); diff --git a/web-common/src/features/dashboards/state-managers/selectors/pivot.ts b/web-common/src/features/dashboards/state-managers/selectors/pivot.ts index 9c2d9857c78f..2b9aaaba64fd 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/pivot.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/pivot.ts @@ -21,7 +21,7 @@ export const pivotSelectors = { dashData.dashboard.pivot.columns, ).measure; - return measures + const specChips = measures .filter((m) => !columnMeasures.find((c) => c.id === m.name)) .map((measure) => ({ id: measure.name || "Unknown", @@ -29,6 +29,18 @@ export const pivotSelectors = { type: PivotChipType.Measure, description: measure.description, })); + + // Unplaced ephemeral measures stay available for re-adding until deleted. + const ephemeralChips = (dashData.dashboard.ephemeralMeasures ?? []) + .filter((def) => !columnMeasures.find((c) => c.id === def.name)) + .map((def) => ({ + id: def.name, + title: def.displayName, + type: PivotChipType.Measure, + description: def.expression, + })); + + return [...specChips, ...ephemeralChips]; }, dimensions: ({ validMetricsView, diff --git a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts index f027cc438e4c..9ae10fc3d096 100644 --- a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts +++ b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts @@ -18,11 +18,15 @@ import { export class AdvancedMeasureCorrector { private measuresMap: Map; private measuresGrains: Map; + private ephemeralMeasureNames: Set; private constructor( private readonly exploreState: ExploreState, private readonly metricsViewSpec: V1MetricsViewSpec, ) { + this.ephemeralMeasureNames = new Set( + exploreState.ephemeralMeasures?.map((def) => def.name) ?? [], + ); this.measuresMap = getMapFromArray( metricsViewSpec.measures ?? [], (m) => m.name ?? "", @@ -58,7 +62,8 @@ export class AdvancedMeasureCorrector { this.exploreState.dimensionThresholdFilters.forEach( (dimensionThreshold) => { dimensionThreshold.filters = dimensionThreshold.filters.filter( - (dtf) => !this.measureIsValidForComponent(dtf.measure, false, false), + (dtf) => + !this.measureIsValidForComponent(dtf.measure, false, false, false), ); }, ); @@ -138,7 +143,13 @@ export class AdvancedMeasureCorrector { measureName: string, supportsComparisonMeasure: boolean, supportsWindowedMeasure: boolean, + supportsEphemeralMeasure = true, ) { + // ephemeral measures are not in the metrics view spec; + // they are validated separately when parsing the URL state. + if (this.ephemeralMeasureNames.has(measureName)) { + return !supportsEphemeralMeasure; + } const measure = this.measuresMap.get(measureName); if (!measure) return true; const grain = diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 6beecd0c54b3..be3376e2c272 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -33,6 +33,8 @@ import { type PivotMeasureFormatting, type PivotTableMode, } from "../pivot/types"; +import { parseMeasureExpression } from "../ephemeral-measures/expression-parser"; +import type { EphemeralMeasureDef } from "../ephemeral-measures/types"; export interface MetricsExplorerStoreType { entities: Record; @@ -72,13 +74,46 @@ export function includeExcludeModeFromFilters( return map; } +// syncEphemeralMeasures drops ephemeral measures that reference measures no +// longer in the explore, so a spec change never leaves views permanently +// erroring. Must run before syncMeasures/syncDimensions, which treat the +// remaining ephemeral measure names as valid. +function syncEphemeralMeasures( + explore: V1ExploreSpec, + exploreState: ExploreState, +) { + if (!exploreState.ephemeralMeasures?.length) return; + const measuresSet = new Set(explore.measures ?? []); + const dimensionsSet = new Set(explore.dimensions ?? []); + exploreState.ephemeralMeasures = exploreState.ephemeralMeasures.filter( + (def) => { + // A field later added to the spec with the same name must win; + // otherwise the stale definition would silently shadow it in requests. + if (measuresSet.has(def.name) || dimensionsSet.has(def.name)) { + return false; + } + const parsed = parseMeasureExpression(def.expression); + if (parsed.error) return false; + return parsed.refs.every((ref) => measuresSet.has(ref)); + }, + ); + if (!exploreState.ephemeralMeasures.length) { + exploreState.ephemeralMeasures = undefined; + } +} + function syncMeasures(explore: V1ExploreSpec, exploreState: ExploreState) { const measuresSet = new Set(explore.measures ?? []); + // Ephemeral measure names are valid anywhere a measure name is used. + const validNames = new Set([ + ...measuresSet, + ...(exploreState.ephemeralMeasures?.map((def) => def.name) ?? []), + ]); // sync measures with selected leaderboard measure and ensure default measure is set if (explore.measures?.length) { const defaultMeasure = explore.measures[0]; - if (!measuresSet.has(exploreState.leaderboardSortByMeasureName)) { + if (!validNames.has(exploreState.leaderboardSortByMeasureName)) { exploreState.leaderboardSortByMeasureName = defaultMeasure; } if (!exploreState.leaderboardMeasureNames?.length) { @@ -99,18 +134,22 @@ function syncMeasures(explore: V1ExploreSpec, exploreState: ExploreState) { if ( exploreState.tdd.expandedMeasureName && - !measuresSet.has(exploreState.tdd.expandedMeasureName) + !validNames.has(exploreState.tdd.expandedMeasureName) ) { exploreState.tdd.expandedMeasureName = undefined; } if (exploreState.allMeasuresVisible) { // this makes sure that the visible keys is in sync with list of measures - exploreState.visibleMeasures = [...measuresSet]; + // (ephemeral measures count as selectable measures too) + exploreState.visibleMeasures = [ + ...measuresSet, + ...(exploreState.ephemeralMeasures?.map((def) => def.name) ?? []), + ]; } else { // remove any visible measures that doesn't exist anymore exploreState.visibleMeasures = exploreState.visibleMeasures.filter((m) => - measuresSet.has(m), + validNames.has(m), ); // If there are no visible measures, make the first measure visible if (explore.measures?.length && exploreState.visibleMeasures.length === 0) { @@ -143,10 +182,15 @@ function syncDimensions(explore: V1ExploreSpec, exploreState: ExploreState) { dimensionsSet.has(dimension.id) || dimension.type === PivotChipType.Time, ); + const ephemeralMeasureNames = new Set( + exploreState.ephemeralMeasures?.map((def) => def.name) ?? [], + ); + exploreState.pivot.columns = exploreState.pivot.columns.filter( (col) => measuresSet.has(col.id) || dimensionsSet.has(col.id) || + ephemeralMeasureNames.has(col.id) || col.type === PivotChipType.Time, ); @@ -229,6 +273,9 @@ const metricsViewReducers = { sync(name: string, explore: V1ExploreSpec) { if (!name || !explore || !explore.measures) return; updateMetricsExplorerByName(name, (exploreState) => { + // remove ephemeral measures referencing non existent measures + syncEphemeralMeasures(explore, exploreState); + // remove references to non existent measures syncMeasures(explore, exploreState); @@ -616,6 +663,90 @@ const metricsViewReducers = { }); }, + addEphemeralMeasure(name: string, def: EphemeralMeasureDef) { + updateMetricsExplorerByName(name, (exploreState) => { + exploreState.ephemeralMeasures = [ + ...(exploreState.ephemeralMeasures ?? []), + def, + ]; + if (exploreState.activePage === DashboardState_ActivePage.PIVOT) { + exploreState.pivot.rowPage = 1; + exploreState.pivot.activeCell = null; + exploreState.pivot.columns.push({ + id: def.name, + title: def.displayName, + type: PivotChipType.Measure, + }); + } else { + // Make the new measure visible in the explore view. + exploreState.visibleMeasures = [ + ...exploreState.visibleMeasures, + def.name, + ]; + exploreState.allMeasuresVisible = false; + } + }); + }, + + updateEphemeralMeasure(name: string, def: EphemeralMeasureDef) { + updateMetricsExplorerByName(name, (exploreState) => { + exploreState.pivot.rowPage = 1; + exploreState.pivot.activeCell = null; + exploreState.ephemeralMeasures = ( + exploreState.ephemeralMeasures ?? [] + ).map((d) => (d.name === def.name ? def : d)); + // Chip titles are denormalized; keep any placed pivot chip in sync on rename. + exploreState.pivot.columns = exploreState.pivot.columns.map((col) => + col.id === def.name ? { ...col, title: def.displayName } : col, + ); + }); + }, + + removeEphemeralMeasure(name: string, measureName: string) { + updateMetricsExplorerByName(name, (exploreState) => { + exploreState.ephemeralMeasures = ( + exploreState.ephemeralMeasures ?? [] + ).filter((d) => d.name !== measureName); + + // Remove all usages across views. + exploreState.pivot.rowPage = 1; + exploreState.pivot.activeCell = null; + exploreState.pivot.columns = exploreState.pivot.columns.filter( + (col) => col.id !== measureName, + ); + exploreState.pivot.sorting = exploreState.pivot.sorting.filter( + (s) => s.id !== measureName, + ); + if (exploreState.pivot.measureFormatting?.[measureName]) { + const measureFormatting = { ...exploreState.pivot.measureFormatting }; + delete measureFormatting[measureName]; + exploreState.pivot.measureFormatting = measureFormatting; + } + + exploreState.visibleMeasures = exploreState.visibleMeasures.filter( + (m) => m !== measureName, + ); + exploreState.leaderboardMeasureNames = ( + exploreState.leaderboardMeasureNames ?? [] + ).filter((m) => m !== measureName); + if (exploreState.leaderboardSortByMeasureName === measureName) { + exploreState.leaderboardSortByMeasureName = + exploreState.leaderboardMeasureNames[0] ?? + exploreState.visibleMeasures[0] ?? + ""; + } + if (exploreState.tdd.expandedMeasureName === measureName) { + exploreState.tdd.expandedMeasureName = undefined; + if ( + exploreState.activePage === + DashboardState_ActivePage.TIME_DIMENSIONAL_DETAIL + ) { + exploreState.activePage = DashboardState_ActivePage.DEFAULT; + } + } + }); + }, + setPivotRowLimitForExpandedRow( name: string, expandIndex: string, diff --git a/web-common/src/features/dashboards/stores/explore-state.ts b/web-common/src/features/dashboards/stores/explore-state.ts index f37434e3a8ff..44e4fb60598f 100644 --- a/web-common/src/features/dashboards/stores/explore-state.ts +++ b/web-common/src/features/dashboards/stores/explore-state.ts @@ -1,3 +1,4 @@ +import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import type { MeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; import { LeaderboardContextColumn, @@ -32,6 +33,15 @@ export interface ExploreState { */ visibleMeasures: string[]; + /** + * Ad-hoc measures derived from existing metrics view measures via an + * arithmetic expression (e.g. Profit = revenue - cost). Shared by all + * views (leaderboards, charts, pivot, ...) and encoded in the `ephemeral` URL + * param. Definitions are independent of usage: an unused definition stays + * available in the measure menus until explicitly deleted. + */ + ephemeralMeasures?: EphemeralMeasureDef[]; + /** * While the `visibleMeasureKeys` has the list of visible measures, * this is explicitly needed to fill the state. diff --git a/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts b/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts index 4ce33ee46075..7fefbb7bf1da 100644 --- a/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts +++ b/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts @@ -1,3 +1,7 @@ +import { + injectEphemeralMeasuresIntoMap, + validateEphemeralDefsAgainstSpec, +} from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-state"; import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; import { getMultiFieldError, @@ -46,6 +50,21 @@ export function validateAndCleanExploreState( (d) => d.name!, ); + // Validate restored ephemeral measures and treat the valid ones as known + // measure names for the validations below. + if (exploreState.ephemeralMeasures !== undefined) { + const { valid, invalidEntries } = validateEphemeralDefsAgainstSpec( + exploreState.ephemeralMeasures, + measures, + dimensions, + ); + exploreState.ephemeralMeasures = valid.length ? valid : undefined; + injectEphemeralMeasuresIntoMap(measures, valid); + if (invalidEntries.length) { + errors.push(getMultiFieldError("calculated measure", invalidEntries)); + } + } + const errorsFromExploreView = validateAndCleanExploreViewState( measures, dimensions, diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte index ba844e368db1..e5c997e6e234 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte @@ -3,6 +3,7 @@ import Column from "@rilldata/web-common/components/icons/Column.svelte"; import Row from "@rilldata/web-common/components/icons/Row.svelte"; import SearchableFilterChip from "@rilldata/web-common/components/searchable-filter-menu/SearchableFilterChip.svelte"; + import { ephemeralMeasureDialog } from "@rilldata/web-common/features/dashboards/ephemeral-measures/dialog-store"; import { splitPivotChips } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; import ReplacePivotDialog from "@rilldata/web-common/features/dashboards/pivot/ReplacePivotDialog.svelte"; import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; @@ -55,16 +56,22 @@ validSpecStore, } = stateManagers; + $: ephemeralDefsByName = new Map( + ($dashboardStore?.ephemeralMeasures ?? []).map((def) => [def.name, def]), + ); + $: selectableMeasures = $allMeasures .filter((m) => m.name !== undefined || m.displayName !== undefined) - .map((m) => + .map((m) => { + const def = ephemeralDefsByName.get(m.name || ""); // Note: undefined values are filtered out above, so the // empty string fallback is unreachable. - ({ + return { name: m.name || "", label: m.displayName || "", - }), - ); + ...(def ? { description: def.expression, ephemeral: true } : {}), + }; + }); $: selectedMeasureLabel = $allMeasures.find((m) => m.name === expandedMeasureName)?.displayName || @@ -207,7 +214,12 @@ /> { + const def = ephemeralDefsByName.get(name); + if (def) ephemeralMeasureDialog.set({ def }); + }} selectableItems={selectableMeasures} selectedItems={[expandedMeasureName]} tooltipText="Choose a measure to display" diff --git a/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts b/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts index 0a3404497bae..8d2b760bf7b8 100644 --- a/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts +++ b/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts @@ -1,3 +1,4 @@ +import { mapEphemeralMeasuresForRequest } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { SortDirection } from "@rilldata/web-common/features/dashboards/proto-state/derived-types"; import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; @@ -86,9 +87,11 @@ export function getTDDAggregationRequest({ } if (!timeRange) return undefined; - const measures: V1MetricsViewAggregationMeasure[] = [ - { name: exploreState.tdd.expandedMeasureName }, - ]; + const measures: V1MetricsViewAggregationMeasure[] = + mapEphemeralMeasuresForRequest( + [{ name: exploreState.tdd.expandedMeasureName }], + exploreState.ephemeralMeasures, + ); // CAST SAFETY: exports are only available in TDD when a comparison dimension is selected const dimensionName = exploreState.selectedComparisonDimension as string; diff --git a/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts b/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts index cfce38c95031..6fc81c3598c3 100644 --- a/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts +++ b/web-common/src/features/dashboards/time-dimension-details/time-dimension-data-store.ts @@ -1,3 +1,4 @@ +import { ephemeralMeasureToSpecMeasure } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { createSparkline } from "@rilldata/web-common/components/data-graphic/marks/sparkline"; import { useSelectedValuesForCompareDimension } from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimension-filters"; import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; @@ -407,9 +408,14 @@ export function createTimeDimensionDataStore( const isAllTime = timeControls?.selectedTimeRange?.name === TimeRangePreset.ALL_TIME; - const measure = validSpec.data?.metricsView?.measures?.find( - (m) => m.name === measureName, - ); + const measure = + validSpec.data?.metricsView?.measures?.find( + (m) => m.name === measureName, + ) ?? + // ephemeral measures have no spec entry; synthesize one. + dashboardStore?.ephemeralMeasures + ?.filter((def) => def.name === measureName) + .map(ephemeralMeasureToSpecMeasure)[0]; let comparing; let data: TableData | undefined = undefined; diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 7395997430d5..9a8ae1e8917f 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -3,6 +3,9 @@ import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import DashboardMetricsDraggableList from "@rilldata/web-common/components/menu/DashboardMetricsDraggableList.svelte"; + import CreateEphemeralMeasureButton from "@rilldata/web-common/features/dashboards/ephemeral-measures/CreateEphemeralMeasureButton.svelte"; + import { ephemeralMeasureDialog } from "@rilldata/web-common/features/dashboards/ephemeral-measures/dialog-store"; + import { ephemeralMeasureNameSet } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import ReplacePivotDialog from "@rilldata/web-common/features/dashboards/pivot/ReplacePivotDialog.svelte"; import { splitPivotChips } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; @@ -168,6 +171,13 @@ $: visibleMeasureNames = $visibleMeasures .map(({ name }) => name) .filter(isDefined); + + function openEphemeralMeasureEditor(name: string) { + const def = $dashboardStore?.ephemeralMeasures?.find( + (d) => d.name === name, + ); + if (def) ephemeralMeasureDialog.set({ def }); + } $: allMeasureNames = $allMeasures.map(({ name }) => name).filter(isDefined); function isDefined(value: string | undefined): value is string { return value !== undefined; @@ -300,7 +310,15 @@ allItems={$allMeasures} tagIndex={$measureTagIndex} selectedItems={visibleMeasureNames} - /> + ephemeralNames={ephemeralMeasureNameSet( + $dashboardStore?.ephemeralMeasures, + )} + onEditEphemeral={openEphemeralMeasureEditor} + > +
+ +
+ {#if $rillTime && activeTimeGrain} @@ -398,6 +416,7 @@ {#each renderedMeasures as measure (measure.name)} + import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { Button } from "@rilldata/web-common/components/button"; import * as Dialog from "@rilldata/web-common/components/dialog"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; @@ -20,6 +21,7 @@ export let open = false; export let measure: MetricsViewSpecMeasure; + export let ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined; export let metricsViewName: string; export let where: V1Expression | undefined = undefined; export let tddChartType: TDDChart = TDDChart.DEFAULT; @@ -140,6 +142,7 @@ + import { splitTimeSeriesMeasures } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; + import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import InlineErrorIndicator from "@rilldata/web-common/features/dashboards/errors/InlineErrorIndicator.svelte"; import TDDMeasureChart from "@rilldata/web-common/features/dashboards/time-dimension-details/charts/TDDChart.svelte"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; @@ -33,6 +35,7 @@ const VISIBILITY_ROOT_MARGIN = "120px"; export let measure: MetricsViewSpecMeasure; + export let ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined; export let metricsViewName: string; export let where: V1Expression | undefined = undefined; export let timeDimension: string | undefined = undefined; @@ -83,6 +86,8 @@ }); $: measureName = measure.name ?? ""; + $: ({ measureNames: tsMeasureNames, ephemeralMeasures: tsEphemeralMeasures } = + splitTimeSeriesMeasures([measureName], ephemeralMeasures)); $: height = showTimeDimensionDetail ? tddChartHeight : 145; $: effectiveChartType = resolveEffectiveChartType( @@ -124,7 +129,8 @@ client, { metricsViewName, - measureNames: [measureName], + measureNames: tsMeasureNames, + ephemeralMeasures: tsEphemeralMeasures, where, timeDimension, timeStart, @@ -145,7 +151,8 @@ client, { metricsViewName, - measureNames: [measureName], + measureNames: tsMeasureNames, + ephemeralMeasures: tsEphemeralMeasures, where, timeDimension, timeStart: comparisonTimeStart, @@ -190,6 +197,7 @@ client, metricsViewName, measureName, + ephemeralMeasures, comparisonDimension!, dimensionValues, dimensionWhere, @@ -208,6 +216,7 @@ client, metricsViewName, measureName, + ephemeralMeasures, comparisonDimension!, dimensionValues, dimensionWhere, diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts index 9383b5afb4ca..914f6cc9d4d6 100644 --- a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts @@ -1,3 +1,5 @@ +import { mapEphemeralMeasuresForRequest } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; +import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { createAndExpression, createInExpression, @@ -33,6 +35,7 @@ export function createDimensionAggregationQuery( client: RuntimeClient, metricsViewName: string, measureName: string, + ephemeralMeasures: EphemeralMeasureDef[] | undefined, dimensionName: string, dimensionValues: (string | null)[], where: V1Expression | undefined, @@ -55,7 +58,10 @@ export function createDimensionAggregationQuery( client, { metricsView: metricsViewName, - measures: [{ name: measureName }], + measures: mapEphemeralMeasuresForRequest( + [{ name: measureName }], + ephemeralMeasures, + ), dimensions: [ { name: dimensionName }, { name: timeDimension, timeGrain: timeGranularity as any, timeZone }, diff --git a/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts b/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts index e4afa8f60a42..c0535458b623 100644 --- a/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts +++ b/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts @@ -1,3 +1,4 @@ +import { mapEphemeralMeasuresForRequest } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { getURIRequestMeasure, URI_DIMENSION_SUFFIX, @@ -134,9 +135,11 @@ export function getDimensionValuesForComparison( const hasUri = !!validSpec?.data?.metricsView?.dimensions?.find( (d) => d.name === dimensionName, )?.uri; - const tddMeasures: V1MetricsViewAggregationMeasure[] = measures.map( - (measure) => ({ name: measure }), - ); + const tddMeasures: V1MetricsViewAggregationMeasure[] = + mapEphemeralMeasuresForRequest( + measures.map((measure) => ({ name: measure })), + dashboardStore.ephemeralMeasures, + ); if (hasUri) { tddMeasures.push(getURIRequestMeasure(dimensionName)); } @@ -294,7 +297,10 @@ function getAggregationQueryForTopList( ctx.runtimeClient, { metricsView: metricsViewName, - measures: measures.map((measure) => ({ name: measure })), + measures: mapEphemeralMeasuresForRequest( + measures.map((measure) => ({ name: measure })), + dashboardStore.ephemeralMeasures, + ), dimensions: [ { name: dimensionName }, { name: timeDimension, timeGrain, timeZone }, diff --git a/web-common/src/features/dashboards/time-series/timeseries-data-store.ts b/web-common/src/features/dashboards/time-series/timeseries-data-store.ts index 41faf475bd93..ceea3c2a91f6 100644 --- a/web-common/src/features/dashboards/time-series/timeseries-data-store.ts +++ b/web-common/src/features/dashboards/time-series/timeseries-data-store.ts @@ -1,3 +1,7 @@ +import { + ephemeralMeasureNameSet, + splitTimeSeriesMeasures, +} from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { filterOutSomeAdvancedMeasures } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures"; import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; @@ -60,11 +64,17 @@ export function createMetricsViewTimeSeries( ([metricsViewName, dashboardStore, timeControls], set) => { const timeGrain = timeControls.selectedTimeRange?.interval; + // Ephemeral measures travel in `measures` with their expression; + // regular measures stay in `measureNames`. + const { measureNames, ephemeralMeasures: ephemeralRequestMeasures } = + splitTimeSeriesMeasures(measures, dashboardStore.ephemeralMeasures); + return createQueryServiceMetricsViewTimeSeries( ctx.runtimeClient, { metricsViewName, - measureNames: measures, + measureNames, + ephemeralMeasures: ephemeralRequestMeasures, where: sanitiseExpression( mergeDimensionAndMeasureFilters( dashboardStore.whereFilter, @@ -129,7 +139,10 @@ export function createTimeSeriesDataStore( ); const expandedMeasuerName = dashboardStore?.tdd?.expandedMeasureName; if (showTimeDimensionDetail && expandedMeasuerName) { - measures = allMeasures.filter( + const ephemeralMeasureNames = ephemeralMeasureNameSet( + dashboardStore?.ephemeralMeasures, + ); + measures = [...allMeasures, ...ephemeralMeasureNames].filter( (measure) => measure === expandedMeasuerName, ); } else { diff --git a/web-common/src/features/dashboards/time-series/totals-data-store.ts b/web-common/src/features/dashboards/time-series/totals-data-store.ts index 5576201e5adb..886ab9cdbb67 100644 --- a/web-common/src/features/dashboards/time-series/totals-data-store.ts +++ b/web-common/src/features/dashboards/time-series/totals-data-store.ts @@ -1,3 +1,4 @@ +import { mapEphemeralMeasuresForRequest } from "@rilldata/web-common/features/dashboards/ephemeral-measures/measure-mapping"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; import { @@ -24,7 +25,10 @@ export function createTotalsForMeasure( ctx.runtimeClient, { metricsView: metricsViewName, - measures: measures.map((measure) => ({ name: measure })), + measures: mapEphemeralMeasuresForRequest( + measures.map((measure) => ({ name: measure })), + dashboard.ephemeralMeasures, + ), where: sanitiseExpression( mergeDimensionAndMeasureFilters( dashboard.whereFilter, @@ -78,7 +82,10 @@ export function createUnfilteredTotalsForMeasure( ctx.runtimeClient, { metricsView: metricsViewName, - measures: measures.map((measure) => ({ name: measure })), + measures: mapEphemeralMeasuresForRequest( + measures.map((measure) => ({ name: measure })), + dashboard.ephemeralMeasures, + ), where: updatedFilter, timeRange: { start: timeControls.timeStart, diff --git a/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts b/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts index 30e9b9a8e48e..f38f685ae19f 100644 --- a/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts +++ b/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts @@ -1,4 +1,5 @@ import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; +import { toEphemeralMeasuresParam } from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-param"; import { toPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param"; import { type PivotChipData, @@ -124,6 +125,15 @@ export function convertPartialExploreStateToUrlParams( searchParams.set(ExploreStateURLParams.Filters, filterParam); } + if ("ephemeralMeasures" in partialExploreState) { + // Always set so deleting the last definition removes it from the URL; + // cleanUrlParams strips the empty value. + searchParams.set( + ExploreStateURLParams.EphemeralMeasures, + toEphemeralMeasuresParam(partialExploreState.ephemeralMeasures), + ); + } + switch (partialExploreState.activePage) { case DashboardState_ActivePage.UNSPECIFIED: case DashboardState_ActivePage.DEFAULT: diff --git a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts index 908e10ff19ef..f8e1adb91c85 100644 --- a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts @@ -1,3 +1,8 @@ +import { toEphemeralMeasuresParam } from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-param"; +import { + injectEphemeralMeasuresIntoMap, + validateEphemeralDefsAgainstSpec, +} from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-state"; import { toPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param"; import { FromProtoTimeGrainMap } from "@rilldata/web-common/features/dashboards/proto-state/enum-maps"; import { convertFilterToExpression } from "@rilldata/web-common/features/dashboards/proto-state/filter-converter"; @@ -71,6 +76,24 @@ export function convertLegacyStateToExplorePreset( (d) => d.name!, ); + if (legacyState.ephemeralMeasures?.length) { + const { valid, invalidEntries } = validateEphemeralDefsAgainstSpec( + legacyState.ephemeralMeasures.map((def) => ({ + name: def.name, + displayName: def.displayName, + expression: def.expression, + ...(def.formatPreset ? { formatPreset: def.formatPreset } : {}), + })), + measures, + dimensions, + ); + preset.ephemeralMeasures = toEphemeralMeasuresParam(valid); + injectEphemeralMeasuresIntoMap(measures, valid); + if (invalidEntries.length) { + errors.push(getMultiFieldError("calculated measure", invalidEntries)); + } + } + if (legacyState.activePage !== DashboardState_ActivePage.UNSPECIFIED) { preset.view = FromActivePageMap[legacyState.activePage]; } diff --git a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts index 6df0c4e0dfd6..344f84639db4 100644 --- a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts +++ b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts @@ -1,4 +1,6 @@ import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; +import { fromEphemeralMeasuresParam } from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-param"; +import { injectEphemeralMeasuresIntoMap } from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-state"; import { fromPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param"; import { type PivotChipData, @@ -68,6 +70,19 @@ export function convertPresetToExploreState( (d) => d.name!, ); + // The preset's ephemeral measures were already validated when the preset + // was built (convertURLToExplorePreset); resolve them and make their names + // valid everywhere a measure name is used below. + if (preset.ephemeralMeasures !== undefined) { + const { ephemeralMeasures } = fromEphemeralMeasuresParam( + preset.ephemeralMeasures, + ); + partialExploreState.ephemeralMeasures = ephemeralMeasures.length + ? ephemeralMeasures + : undefined; + injectEphemeralMeasuresIntoMap(measures, ephemeralMeasures); + } + if (preset.view) { partialExploreState.activePage = Number( ToActivePageViewMap[preset.view] ?? "0", diff --git a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts index ee9ae56cbeb9..fe54e1d5471b 100644 --- a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts @@ -1,4 +1,9 @@ import { stripMeasureSuffix } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; +import { toEphemeralMeasuresParam } from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-param"; +import { + injectEphemeralMeasuresIntoMap, + parseAndValidateEphemeralParam, +} from "@rilldata/web-common/features/dashboards/ephemeral-measures/url-state"; import { PIVOT_ROW_LIMIT_OPTIONS } from "@rilldata/web-common/features/dashboards/pivot/pivot-constants"; import { fromPivotFormattingParam, @@ -109,6 +114,27 @@ export function convertURLToExplorePreset( errors.push(...errorsFromLegacyState); } + // Parse ephemeral measures before any other param: their names are valid + // measure names everywhere (visible measures, leaderboards, sort, pivot + // columns, formatting, ...), which is achieved by injecting synthetic spec + // measures into the `measures` map used by all validations below. + if (searchParams.has(ExploreStateURLParams.EphemeralMeasures)) { + const ephemeralParam = searchParams.get( + ExploreStateURLParams.EphemeralMeasures, + ) as string; + const { valid, invalidEntries } = parseAndValidateEphemeralParam( + ephemeralParam, + measures, + // Includes time dimensions so a definition cannot shadow one. + allDimensions, + ); + preset.ephemeralMeasures = toEphemeralMeasuresParam(valid); + injectEphemeralMeasuresIntoMap(measures, valid); + if (invalidEntries.length) { + errors.push(getMultiFieldError("calculated measure", invalidEntries)); + } + } + if (searchParams.has(ExploreStateURLParams.WebView)) { const view = searchParams.get(ExploreStateURLParams.WebView) as string; if (view in FromURLParamViewMap) { diff --git a/web-common/src/features/dashboards/url-state/url-params.ts b/web-common/src/features/dashboards/url-state/url-params.ts index 4971206d9e88..878c748f1234 100644 --- a/web-common/src/features/dashboards/url-state/url-params.ts +++ b/web-common/src/features/dashboards/url-state/url-params.ts @@ -34,6 +34,7 @@ export enum ExploreStateURLParams { PivotShowTotalsColumn = "show_totals_column", PivotShowTotalsRow = "show_totals_row", PivotFormatting = "format", + EphemeralMeasures = "ephemeral", DynamicYAxisScale = "dyn_y", diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 41fc8060ba19..d5b99184fc34 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -5,6 +5,8 @@ extractErrorStatusCode, isNotFoundError, } from "@rilldata/web-common/lib/errors"; + import EphemeralMeasureDialog from "@rilldata/web-common/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte"; + import { ephemeralMeasureDialog } from "@rilldata/web-common/features/dashboards/ephemeral-measures/dialog-store"; import PivotDisplay from "@rilldata/web-common/features/dashboards/pivot/PivotDisplay.svelte"; import TabBar from "@rilldata/web-common/features/dashboards/tab-bar/TabBar.svelte"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; @@ -293,6 +295,10 @@ {/if} + + {#if $ephemeralMeasureDialog} + + {/if} diff --git a/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte index ad1fd15b9781..87c9b44de8eb 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte +++ b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte @@ -6,6 +6,7 @@ import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; + import { ephemeralFormatPresetOptions } from "./format-presets"; import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; import { get } from "svelte/store"; import type { EphemeralMeasureDef } from "./types"; @@ -26,13 +27,7 @@ const { exploreName, validSpecStore, dashboardStore } = getStateManagers(); - const FORMAT_PRESETS: { value: string; label: string }[] = [ - { value: FormatPreset.HUMANIZE, label: "Humanize" }, - { value: FormatPreset.NONE, label: "None" }, - { value: FormatPreset.CURRENCY_USD, label: "Currency (USD)" }, - { value: FormatPreset.CURRENCY_EUR, label: "Currency (EUR)" }, - { value: FormatPreset.PERCENTAGE, label: "Percentage" }, - ]; + const FORMAT_PRESETS = ephemeralFormatPresetOptions(); let displayName = editingDef?.displayName ?? ""; let expression = editingDef?.expression ?? ""; @@ -149,7 +144,7 @@ bind:value={displayName} id="ephemeral-measure-name" label={m.dashboard_pivot_ephemeral_display_name_label()} - placeholder="Profit" + placeholder={m.dashboard_pivot_ephemeral_display_name_placeholder()} claimFocusOnMount /> diff --git a/web-common/src/features/dashboards/ephemeral-measures/format-presets.ts b/web-common/src/features/dashboards/ephemeral-measures/format-presets.ts new file mode 100644 index 000000000000..85e699853ff4 --- /dev/null +++ b/web-common/src/features/dashboards/ephemeral-measures/format-presets.ts @@ -0,0 +1,30 @@ +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; + +/** + * Format presets offered when defining an ephemeral measure. + */ +export function ephemeralFormatPresetOptions(): { + value: string; + label: string; +}[] { + return [ + { + value: FormatPreset.HUMANIZE, + label: m.dashboard_pivot_ephemeral_format_humanize(), + }, + { value: FormatPreset.NONE, label: m.common_none() }, + { + value: FormatPreset.CURRENCY_USD, + label: m.dashboard_pivot_ephemeral_format_currency_usd(), + }, + { + value: FormatPreset.CURRENCY_EUR, + label: m.dashboard_pivot_ephemeral_format_currency_eur(), + }, + { + value: FormatPreset.PERCENTAGE, + label: m.dashboard_pivot_ephemeral_format_percentage(), + }, + ]; +} diff --git a/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte b/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte index f65318745841..4c0aae1f92fc 100644 --- a/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte +++ b/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte @@ -81,7 +81,7 @@ {item.title} - Conditional formatting{#if fmt?.mode === "rules"} + {m.dashboard_pivot_conditional_formatting()}{#if fmt?.mode === "rules"} · {fmt.rules.length} {fmt.rules.length === 1 ? "rule" : "rules"}{/if} diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index e1d80574ebad..91ee518f3171 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -441,6 +441,9 @@ "canvas_max_width": "Max width", "canvas_measure_label": "Measure", "canvas_ephemeral_measures_label": "Calculated measures", + "canvas_field_choose_field": "Choose a field...", + "canvas_field_no_results": "no results", + "canvas_field_time": "Time", "canvas_measures_label": "Measures", "canvas_metrics_sql_label": "Metrics SQL", "canvas_metrics_view_description": "This will determine the measures and dimensions you can explore on this dashboard.", @@ -813,15 +816,21 @@ "dashboard_percentage_change": "Percentage change over comparison period", "dashboard_pivot": "Pivot", "dashboard_pivot_add_measure": "Add a measure to complete your table.", + "dashboard_pivot_conditional_formatting": "Conditional formatting", "dashboard_pivot_ephemeral_measure": "Calculated measure", "dashboard_pivot_ephemeral_create": "Create calculated measure", "dashboard_pivot_ephemeral_display_name_label": "Name", + "dashboard_pivot_ephemeral_display_name_placeholder": "Profit", "dashboard_pivot_ephemeral_edit": "Edit", "dashboard_pivot_ephemeral_edit_title": "Edit calculated measure", "dashboard_pivot_ephemeral_expression_hint": "Combine existing measures with + - * / % and functions like round() or coalesce().", "dashboard_pivot_ephemeral_expression_label": "Expression", "dashboard_pivot_ephemeral_expression_placeholder": "e.g. revenue - cost", "dashboard_pivot_ephemeral_format_label": "Format", + "dashboard_pivot_ephemeral_format_humanize": "Humanize", + "dashboard_pivot_ephemeral_format_currency_usd": "Currency (USD)", + "dashboard_pivot_ephemeral_format_currency_eur": "Currency (EUR)", + "dashboard_pivot_ephemeral_format_percentage": "Percentage", "dashboard_pivot_ephemeral_insert_measure": "Insert a measure:", "dashboard_pivot_ephemeral_new_title": "New calculated measure", "dashboard_pivot_ephemeral_save": "Save", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index 51bebb14ca90..bf77d3bdb0fa 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -441,6 +441,9 @@ "canvas_max_width": "Ancho máximo", "canvas_measure_label": "Medida", "canvas_ephemeral_measures_label": "Medidas calculadas", + "canvas_field_choose_field": "Elige un campo...", + "canvas_field_no_results": "sin resultados", + "canvas_field_time": "Tiempo", "canvas_measures_label": "Medidas", "canvas_metrics_sql_label": "SQL de métricas", "canvas_metrics_view_description": "Esto determinará las medidas y dimensiones que puedes explorar en este dashboard.", @@ -813,15 +816,21 @@ "dashboard_percentage_change": "Cambio porcentual respecto al período de comparación", "dashboard_pivot": "Pivote", "dashboard_pivot_add_measure": "Agrega una medida para completar tu tabla.", + "dashboard_pivot_conditional_formatting": "Formato condicional", "dashboard_pivot_ephemeral_measure": "Medida calculada", "dashboard_pivot_ephemeral_create": "Crear medida calculada", "dashboard_pivot_ephemeral_display_name_label": "Nombre", + "dashboard_pivot_ephemeral_display_name_placeholder": "Beneficio", "dashboard_pivot_ephemeral_edit": "Editar", "dashboard_pivot_ephemeral_edit_title": "Editar medida calculada", "dashboard_pivot_ephemeral_expression_hint": "Combina medidas existentes con + - * / % y funciones como round() o coalesce().", "dashboard_pivot_ephemeral_expression_label": "Expresión", "dashboard_pivot_ephemeral_expression_placeholder": "p. ej. revenue - cost", "dashboard_pivot_ephemeral_format_label": "Formato", + "dashboard_pivot_ephemeral_format_humanize": "Humanizar", + "dashboard_pivot_ephemeral_format_currency_usd": "Moneda (USD)", + "dashboard_pivot_ephemeral_format_currency_eur": "Moneda (EUR)", + "dashboard_pivot_ephemeral_format_percentage": "Porcentaje", "dashboard_pivot_ephemeral_insert_measure": "Insertar una medida:", "dashboard_pivot_ephemeral_new_title": "Nueva medida calculada", "dashboard_pivot_ephemeral_save": "Guardar", From 17af3e96d2ea149bd560ed47a1cc07d11d873f27 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Sat, 5 Sep 2026 17:07:19 +0530 Subject: [PATCH 3/5] Name the canvas renderer property `calculated_measures` The YAML property on canvas components keeps the user-facing name `calculated_measures`; the runtime validator and tests follow. Code identifiers stay ephemeral-named. Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf --- runtime/canvas/component.go | 10 +++++----- runtime/canvas/component_test.go | 20 +++++++++---------- .../canvas/components/charts/BaseChart.ts | 8 ++++---- .../canvas/components/kpi-grid/KPIGrid.svelte | 2 +- .../canvas/components/kpi-grid/index.ts | 8 ++++---- .../canvas/components/kpi/KPIProvider.svelte | 2 +- .../features/canvas/components/kpi/index.ts | 2 +- .../canvas/components/kpi/selector.ts | 2 +- .../leaderboard/LeaderboardDisplay.svelte | 2 +- .../canvas/components/leaderboard/index.ts | 8 ++++---- .../canvas/components/leaderboard/selector.ts | 2 +- .../pivot/CanvasPivotDisplay.svelte | 4 ++-- .../features/canvas/components/pivot/index.ts | 14 ++++++------- .../canvas/components/pivot/selector.ts | 4 ++-- .../features/canvas/components/pivot/util.ts | 4 ++-- .../canvas/inspector/ParamMapper.svelte | 6 +++--- .../fields/EphemeralMeasureEditor.svelte | 10 +++++----- .../src/features/canvas/inspector/types.ts | 2 +- .../cartesian/CartesianChartProvider.ts | 2 +- .../charts/circular/CircularChartProvider.ts | 2 +- .../charts/combo/ComboChartProvider.ts | 2 +- .../components/charts/ephemeral-measures.ts | 8 ++++---- .../charts/funnel/FunnelChartProvider.ts | 2 +- .../charts/heatmap/HeatmapChartProvider.ts | 2 +- .../scatter/ScatterPlotChartProvider.ts | 2 +- .../src/features/components/charts/types.ts | 2 +- 26 files changed, 66 insertions(+), 66 deletions(-) diff --git a/runtime/canvas/component.go b/runtime/canvas/component.go index fa215d16f17c..928f75d65595 100644 --- a/runtime/canvas/component.go +++ b/runtime/canvas/component.go @@ -592,28 +592,28 @@ func isEncodedTimeDimension(mv *runtimev1.MetricsViewSpec, fieldName string) boo return ok && v != int32(runtimev1.TimeGrain_TIME_GRAIN_UNSPECIFIED) } -// ephemeralMeasureNames extracts and validates the optional "ephemeral_measures" renderer property. +// ephemeralMeasureNames extracts and validates the optional "calculated_measures" renderer property. // Each entry defines an ephemeral measure derived from existing measures via an arithmetic expression; // the returned set contains the names that may be referenced alongside the metrics view's own measures. func ephemeralMeasureNames(props map[string]any, mvn string, mv *runtimev1.MetricsViewSpec) (map[string]bool, error) { - raw, ok := props["ephemeral_measures"] + raw, ok := props["calculated_measures"] if !ok || raw == nil { return nil, nil } list, ok := raw.([]any) if !ok { - return nil, errors.New("renderer property 'ephemeral_measures' must be an array") + return nil, errors.New("renderer property 'calculated_measures' must be an array") } names := make(map[string]bool, len(list)) for _, item := range list { entry, ok := item.(map[string]any) if !ok { - return nil, errors.New("entries in 'ephemeral_measures' must be objects with 'name' and 'expression'") + return nil, errors.New("entries in 'calculated_measures' must be objects with 'name' and 'expression'") } name, _ := entry["name"].(string) expression, _ := entry["expression"].(string) if name == "" || expression == "" { - return nil, errors.New("entries in 'ephemeral_measures' must have a non-empty 'name' and 'expression'") + return nil, errors.New("entries in 'calculated_measures' must have a non-empty 'name' and 'expression'") } // Mirror metricsview.AST.checkNameForComputedField, which also rejects the time dimension. // It is often absent from mv.Dimensions, so checking it here surfaces the collision at parse time rather than at query time. diff --git a/runtime/canvas/component_test.go b/runtime/canvas/component_test.go index e9eef9fd509a..ff366237ca63 100644 --- a/runtime/canvas/component_test.go +++ b/runtime/canvas/component_test.go @@ -979,7 +979,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [y, profit] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -995,7 +995,7 @@ leaderboard: metrics_view: mv1 measures: [profit] dimensions: [foo] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -1010,7 +1010,7 @@ type: component table: metrics_view: mv1 columns: [foo, y, profit] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -1026,7 +1026,7 @@ pivot: metrics_view: mv1 measures: [profit] row_dimensions: [foo] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -1047,7 +1047,7 @@ bar_chart: field: profit type: quantitative fields: [y, profit] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -1067,7 +1067,7 @@ pie_chart: color: field: foo type: nominal - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -1087,7 +1087,7 @@ heatmap: color: field: profit type: quantitative - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z @@ -1119,7 +1119,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [profit] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: sum(y) @@ -1135,7 +1135,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [profit] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - unknown @@ -1151,7 +1151,7 @@ type: component kpi_grid: metrics_view: mv1 measures: [missing] - ephemeral_measures: + calculated_measures: - name: profit display_name: Profit expression: y - z diff --git a/web-common/src/features/canvas/components/charts/BaseChart.ts b/web-common/src/features/canvas/components/charts/BaseChart.ts index d9be32aea1d1..4e52bba8e85c 100644 --- a/web-common/src/features/canvas/components/charts/BaseChart.ts +++ b/web-common/src/features/canvas/components/charts/BaseChart.ts @@ -92,8 +92,8 @@ export abstract class BaseChart< options: { metrics_view: { type: "metrics", label: m.canvas_metrics_view_label() }, // Managed through the measure selectors' create/edit dialog. - ephemeral_measures: { - type: "ephemeral_measures", + calculated_measures: { + type: "calculated_measures", optional: true, showInUI: false, }, @@ -242,7 +242,7 @@ export abstract class BaseChart< ): Partial { const { metrics_view, - ephemeral_measures, + calculated_measures, title, description, vl_config, @@ -275,7 +275,7 @@ export abstract class BaseChart< return { metrics_view, - ...(ephemeral_measures ? { ephemeral_measures } : {}), + ...(calculated_measures ? { calculated_measures } : {}), title, description, vl_config, diff --git a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte index 6dfea51301c6..60d6b08701ef 100644 --- a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte +++ b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte @@ -23,7 +23,7 @@ $: kpis = (kpiGridProperties.measures || []).map((measure) => ({ metrics_view: kpiGridProperties.metrics_view, measure, - ephemeral_measures: kpiGridProperties.ephemeral_measures, + calculated_measures: kpiGridProperties.calculated_measures, sparkline: kpiGridProperties.sparkline, hide_time_range: kpiGridProperties.hide_time_range, comparison: kpiGridProperties.comparison, diff --git a/web-common/src/features/canvas/components/kpi-grid/index.ts b/web-common/src/features/canvas/components/kpi-grid/index.ts index 74a290feed25..d6441a26e339 100644 --- a/web-common/src/features/canvas/components/kpi-grid/index.ts +++ b/web-common/src/features/canvas/components/kpi-grid/index.ts @@ -37,7 +37,7 @@ export interface KPIGridSpec metrics_view: string; measures: string[]; // Ad-hoc measures derived from existing measures via an arithmetic expression. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; // Defaults to "bottom" sparkline?: "none" | "bottom" | "right"; // Defaults to false if undefined; @@ -49,7 +49,7 @@ export interface KPIGridSpec export class KPIGridComponent extends BaseCanvasComponent { minSize = { width: 2, height: 2 }; defaultSize = { width: 6, height: 4 }; - resetParams = ["measures", "ephemeral_measures"]; + resetParams = ["measures", "calculated_measures"]; type: CanvasComponentType = "kpi_grid"; component = KPIGrid; @@ -106,8 +106,8 @@ export class KPIGridComponent extends BaseCanvasComponent { meta: { allowedTypes: ["measure"] }, label: m.canvas_measures_label(), }, - ephemeral_measures: { - type: "ephemeral_measures", + calculated_measures: { + type: "calculated_measures", label: m.canvas_ephemeral_measures_label(), optional: true, // Managed through the measures selector's create/edit dialog. diff --git a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte index cc2c509ef2c9..eca891360e53 100644 --- a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte +++ b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte @@ -54,7 +54,7 @@ $: schema = validateKPISchema(ctx, spec); $: ({ isValid } = $schema); - $: ephemeralMeasures = ephemeralSpecsToDefs(spec.ephemeral_measures); + $: ephemeralMeasures = ephemeralSpecsToDefs(spec.calculated_measures); $: ephemeralDef = ephemeralMeasures?.find((def) => def.name === measureName); $: measureStore = getMeasureForMetricView(measureName, metricsViewName); diff --git a/web-common/src/features/canvas/components/kpi/index.ts b/web-common/src/features/canvas/components/kpi/index.ts index cf573e2b7c36..f6742c02f7b2 100644 --- a/web-common/src/features/canvas/components/kpi/index.ts +++ b/web-common/src/features/canvas/components/kpi/index.ts @@ -97,7 +97,7 @@ export interface KPISpec measure: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; `measure` may name one of them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; // Defaults to "bottom" sparkline?: "none" | "bottom" | "right"; // Defaults to "delta" and "percent_change" diff --git a/web-common/src/features/canvas/components/kpi/selector.ts b/web-common/src/features/canvas/components/kpi/selector.ts index 1f6fe4066319..12e85ccfb003 100644 --- a/web-common/src/features/canvas/components/kpi/selector.ts +++ b/web-common/src/features/canvas/components/kpi/selector.ts @@ -33,7 +33,7 @@ export function validateKPISchema( const validateMeasuresRes = validateMeasures( metricsView, [measure], - new Set(kpiSpec.ephemeral_measures?.map((c) => c.name) ?? []), + new Set(kpiSpec.calculated_measures?.map((c) => c.name) ?? []), ); if (!validateMeasuresRes.isValid) { const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", "); diff --git a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte index 3141f5633e2d..a1acf593415a 100644 --- a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte @@ -91,7 +91,7 @@ .filter((d) => d !== undefined); $: ephemeralMeasures = ephemeralSpecsToDefs( - leaderboardProperties.ephemeral_measures, + leaderboardProperties.calculated_measures, ); $: allMeasuresWithEphemeral = appendEphemeralSpecMeasures( $allMeasures, diff --git a/web-common/src/features/canvas/components/leaderboard/index.ts b/web-common/src/features/canvas/components/leaderboard/index.ts index 0a7ee559faa4..c61a3ffbf415 100644 --- a/web-common/src/features/canvas/components/leaderboard/index.ts +++ b/web-common/src/features/canvas/components/leaderboard/index.ts @@ -45,7 +45,7 @@ export interface LeaderboardSpec metrics_view: string; measures: string[]; // Ad-hoc measures derived from existing measures via an arithmetic expression. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; dimensions: string[]; num_rows: number; } @@ -53,7 +53,7 @@ export interface LeaderboardSpec export class LeaderboardComponent extends BaseCanvasComponent { minSize = { width: 3, height: 3 }; defaultSize = { width: 6, height: 3 }; - resetParams = ["measures", "dimensions", "ephemeral_measures"]; + resetParams = ["measures", "dimensions", "calculated_measures"]; type: CanvasComponentType = "leaderboard"; component = Leaderboard; leaderboardState: Writable; @@ -110,8 +110,8 @@ export class LeaderboardComponent extends BaseCanvasComponent { meta: { allowedTypes: ["measure"] }, label: m.canvas_measures_label(), }, - ephemeral_measures: { - type: "ephemeral_measures", + calculated_measures: { + type: "calculated_measures", label: m.canvas_ephemeral_measures_label(), optional: true, // Managed through the measures selector's create/edit dialog. diff --git a/web-common/src/features/canvas/components/leaderboard/selector.ts b/web-common/src/features/canvas/components/leaderboard/selector.ts index 6f3b4b5a9b7b..be7a64ba1f46 100644 --- a/web-common/src/features/canvas/components/leaderboard/selector.ts +++ b/web-common/src/features/canvas/components/leaderboard/selector.ts @@ -32,7 +32,7 @@ export function validateLeaderboardSchema( } const ephemeralMeasureNames = new Set( - leaderboardSpec?.ephemeral_measures?.map((c) => c.name) ?? [], + leaderboardSpec?.calculated_measures?.map((c) => c.name) ?? [], ); const allMeasures = [ ...(metricsView?.measures?.map((m) => m.name as string) || []), diff --git a/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte b/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte index da9bdffe812b..22b791cc743c 100644 --- a/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte +++ b/web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte @@ -60,7 +60,7 @@ columns: tableFieldMapper( columns, metricsViewSpec, - tableSpec.ephemeral_measures, + tableSpec.calculated_measures, ), showTotalsColumn: tableSpec.hide_totals_col !== true, showTotalsRow: tableSpec.hide_totals_row !== true, @@ -82,7 +82,7 @@ ...tableFieldMapper( measures, metricsViewSpec, - tableSpec.ephemeral_measures, + tableSpec.calculated_measures, ), ], rows: tableFieldMapper(rowDimensions, metricsViewSpec), diff --git a/web-common/src/features/canvas/components/pivot/index.ts b/web-common/src/features/canvas/components/pivot/index.ts index 3505e82a2f5d..95c67a4c0291 100644 --- a/web-common/src/features/canvas/components/pivot/index.ts +++ b/web-common/src/features/canvas/components/pivot/index.ts @@ -102,7 +102,7 @@ export interface PivotSpec metrics_view: string; measures: string[]; // Ad-hoc measures derived from existing measures via an arithmetic expression. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; row_dimensions?: string[]; col_dimensions?: string[]; hide_totals_row?: boolean; @@ -117,7 +117,7 @@ export interface TableSpec metrics_view: string; columns: string[]; // Ad-hoc measures derived from existing measures via an arithmetic expression. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; hide_totals_row?: boolean; hide_totals_col?: boolean; conditional_format?: PivotConditionalFormatSpec[]; @@ -137,7 +137,7 @@ export class PivotCanvasComponent extends BaseCanvasComponent< "row_dimensions", "col_dimensions", "conditional_format", - "ephemeral_measures", + "calculated_measures", ]; type: CanvasComponentType; component = CanvasPivotDisplay; @@ -271,8 +271,8 @@ export class PivotCanvasComponent extends BaseCanvasComponent< meta: { allowedTypes: ["measure"] }, label: m.canvas_measures_label(), }, - ephemeral_measures: { - type: "ephemeral_measures", + calculated_measures: { + type: "calculated_measures", label: m.canvas_ephemeral_measures_label(), optional: true, // Managed through the measures selector's create/edit dialog. @@ -341,8 +341,8 @@ export class PivotCanvasComponent extends BaseCanvasComponent< label: m.canvas_columns_label(), meta: { allowedTypes: ["time", "dimension", "measure"] }, }, - ephemeral_measures: { - type: "ephemeral_measures", + calculated_measures: { + type: "calculated_measures", label: m.canvas_ephemeral_measures_label(), optional: true, // Managed through the measures selector's create/edit dialog. diff --git a/web-common/src/features/canvas/components/pivot/selector.ts b/web-common/src/features/canvas/components/pivot/selector.ts index 2e8326174dcb..4e575c18d7cd 100644 --- a/web-common/src/features/canvas/components/pivot/selector.ts +++ b/web-common/src/features/canvas/components/pivot/selector.ts @@ -41,7 +41,7 @@ export function validateTableSchema( function validateFlat(tableSpec: TableSpec, metricsView: V1MetricsViewSpec) { const ephemeralMeasureNames = new Set( - tableSpec.ephemeral_measures?.map((c) => c.name) ?? [], + tableSpec.calculated_measures?.map((c) => c.name) ?? [], ); const allMeasures = [ ...(metricsView?.measures?.map((m) => m.name as string) || []), @@ -92,7 +92,7 @@ function validateFlat(tableSpec: TableSpec, metricsView: V1MetricsViewSpec) { function validatePivot(tableSpec: PivotSpec, metricsView: V1MetricsViewSpec) { const ephemeralMeasureNames = new Set( - tableSpec.ephemeral_measures?.map((c) => c.name) ?? [], + tableSpec.calculated_measures?.map((c) => c.name) ?? [], ); const measures = tableSpec.measures || []; const rowDimensions = tableSpec.row_dimensions || []; diff --git a/web-common/src/features/canvas/components/pivot/util.ts b/web-common/src/features/canvas/components/pivot/util.ts index e2d4caff8f40..ee880a62aefc 100644 --- a/web-common/src/features/canvas/components/pivot/util.ts +++ b/web-common/src/features/canvas/components/pivot/util.ts @@ -210,7 +210,7 @@ export function processPivot( $timeAndFilterStore.showTimeComparison; const ephemeralMeasures = ephemeralSpecsToDefs( - $tableSpec?.ephemeral_measures, + $tableSpec?.calculated_measures, ); const ephemeralMeasureNames = ephemeralMeasureNameSet(ephemeralMeasures); @@ -300,7 +300,7 @@ export function processFlat( const columns = $tableSpec?.columns || []; const ephemeralMeasures = ephemeralSpecsToDefs( - $tableSpec?.ephemeral_measures, + $tableSpec?.calculated_measures, ); const ephemeralMeasureNames = ephemeralMeasureNameSet(ephemeralMeasures); const allMeasureNames = [ diff --git a/web-common/src/features/canvas/inspector/ParamMapper.svelte b/web-common/src/features/canvas/inspector/ParamMapper.svelte index 40da69a6abb1..2d77cd7d10bd 100644 --- a/web-common/src/features/canvas/inspector/ParamMapper.svelte +++ b/web-common/src/features/canvas/inspector/ParamMapper.svelte @@ -58,14 +58,14 @@ "metrics_view" in localParamValues ? localParamValues.metrics_view : null; $: componentEphemeralMeasures = ephemeralSpecsToDefs( - "ephemeral_measures" in localParamValues - ? localParamValues.ephemeral_measures + "calculated_measures" in localParamValues + ? localParamValues.calculated_measures : undefined, ); // Components that support ephemeral measures declare the param (hidden from // the UI); their measure selectors then offer creating and editing them. - $: supportsEphemeralMeasures = "ephemeral_measures" in inputParams; + $: supportsEphemeralMeasures = "calculated_measures" in inputParams; $: entries = Object.entries(inputParams) as [ AllKeys, diff --git a/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte b/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte index 178119221ecc..46c2e8571029 100644 --- a/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte +++ b/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte @@ -42,7 +42,7 @@ const client = useRuntimeClient(); // The spec key holding the definitions on every component that supports them. - const EPHEMERAL_SPEC_KEY = "ephemeral_measures" as AllKeys; + const EPHEMERAL_SPEC_KEY = "calculated_measures" as AllKeys; const FORMAT_PRESETS = ephemeralFormatPresetOptions(); @@ -59,8 +59,8 @@ $: specStore = component.specStore; $: defs = ephemeralSpecsToDefs( - ($specStore as { ephemeral_measures?: EphemeralMeasureSpec[] }) - .ephemeral_measures, + ($specStore as { calculated_measures?: EphemeralMeasureSpec[] }) + .calculated_measures, ) ?? []; $: referenceableMeasures = (metricsViewSpec?.measures ?? []).filter( @@ -93,8 +93,8 @@ // added or reordered elsewhere (code editor, another refetch) while the // editor was open. Unknown or partial entries are preserved verbatim. function currentRawSpecs(): EphemeralMeasureSpec[] { - const raw = ($specStore as { ephemeral_measures?: EphemeralMeasureSpec[] }) - .ephemeral_measures; + const raw = ($specStore as { calculated_measures?: EphemeralMeasureSpec[] }) + .calculated_measures; return Array.isArray(raw) ? [...raw] : []; } diff --git a/web-common/src/features/canvas/inspector/types.ts b/web-common/src/features/canvas/inspector/types.ts index 488ebfac83c0..1740099b4445 100644 --- a/web-common/src/features/canvas/inspector/types.ts +++ b/web-common/src/features/canvas/inspector/types.ts @@ -10,7 +10,7 @@ type SemanticInputTypes = | "dimension" | "multi_fields" | "multi_fields_format" - | "ephemeral_measures" + | "calculated_measures" | "metrics_sql"; type NativeInputTypes = "text" | "number" | "boolean" | "textarea" | "select"; type ChartInputTypes = "positional" | "mark" | "tooltip" | "config"; diff --git a/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts b/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts index f9b2f7cb086a..5673dbc469d0 100644 --- a/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts +++ b/web-common/src/features/components/charts/cartesian/CartesianChartProvider.ts @@ -51,7 +51,7 @@ export type CartesianChartSpec = { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"nominal" | "time">; y?: FieldConfig<"quantitative">; color?: FieldConfig<"nominal"> | string; diff --git a/web-common/src/features/components/charts/circular/CircularChartProvider.ts b/web-common/src/features/components/charts/circular/CircularChartProvider.ts index ff6654be94f1..37d536b51176 100644 --- a/web-common/src/features/components/charts/circular/CircularChartProvider.ts +++ b/web-common/src/features/components/charts/circular/CircularChartProvider.ts @@ -44,7 +44,7 @@ export type CircularChartSpec = { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; measure?: FieldConfig<"quantitative">; color?: FieldConfig<"nominal">; innerRadius?: number; diff --git a/web-common/src/features/components/charts/combo/ComboChartProvider.ts b/web-common/src/features/components/charts/combo/ComboChartProvider.ts index ef8d204f89df..f23817893f9a 100644 --- a/web-common/src/features/components/charts/combo/ComboChartProvider.ts +++ b/web-common/src/features/components/charts/combo/ComboChartProvider.ts @@ -40,7 +40,7 @@ export type ComboChartSpec = { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"nominal" | "time">; y1?: FieldConfig<"quantitative" | "mark">; y2?: FieldConfig<"quantitative" | "mark">; diff --git a/web-common/src/features/components/charts/ephemeral-measures.ts b/web-common/src/features/components/charts/ephemeral-measures.ts index efdf25c8f2b3..06dd250e70af 100644 --- a/web-common/src/features/components/charts/ephemeral-measures.ts +++ b/web-common/src/features/components/charts/ephemeral-measures.ts @@ -11,7 +11,7 @@ import type { CommonChartProperties } from "./types"; type EphemeralMeasureCarrier = Pick< CommonChartProperties, - "ephemeral_measures" + "calculated_measures" >; /** @@ -24,7 +24,7 @@ export function withEphemeralMeasures( ): V1MetricsViewAggregationMeasure[] { return mapEphemeralMeasuresForRequest( measures, - ephemeralSpecsToDefs(config.ephemeral_measures), + ephemeralSpecsToDefs(config.calculated_measures), ); } @@ -35,7 +35,7 @@ export function chartEphemeralMeasureNames( config: EphemeralMeasureCarrier, ): Set { return new Set( - ephemeralSpecsToDefs(config.ephemeral_measures)?.map((def) => def.name) ?? + ephemeralSpecsToDefs(config.calculated_measures)?.map((def) => def.name) ?? [], ); } @@ -49,7 +49,7 @@ export function resolveEphemeralMeasureSpec( config: EphemeralMeasureCarrier, name: string, ): MetricsViewSpecMeasure | undefined { - const def = ephemeralSpecsToDefs(config.ephemeral_measures)?.find( + const def = ephemeralSpecsToDefs(config.calculated_measures)?.find( (d) => d.name === name, ); return def ? ephemeralMeasureToSpecMeasure(def) : undefined; diff --git a/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts b/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts index 3b8f927c0987..a9b40eb3bcb2 100644 --- a/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts +++ b/web-common/src/features/components/charts/funnel/FunnelChartProvider.ts @@ -41,7 +41,7 @@ export type FunnelChartSpec = { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; breakdownMode?: FunnelBreakdownMode; measure?: FieldConfig<"quantitative">; stage?: FieldConfig<"nominal">; diff --git a/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts b/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts index c035f07e706f..f2ea8460e8c9 100644 --- a/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts +++ b/web-common/src/features/components/charts/heatmap/HeatmapChartProvider.ts @@ -37,7 +37,7 @@ export type HeatmapChartSpec = { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"nominal" | "time">; y?: FieldConfig<"nominal" | "time">; color?: FieldConfig<"quantitative">; diff --git a/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts b/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts index 232597559bbf..2a8a0fd8e838 100644 --- a/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts +++ b/web-common/src/features/components/charts/scatter/ScatterPlotChartProvider.ts @@ -35,7 +35,7 @@ export type ScatterPlotChartSpec = { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; x?: FieldConfig<"quantitative" | "time">; y?: FieldConfig<"quantitative" | "time">; dimension?: FieldConfig<"nominal">; diff --git a/web-common/src/features/components/charts/types.ts b/web-common/src/features/components/charts/types.ts index b0f5b5d0fd46..baa41560cd20 100644 --- a/web-common/src/features/components/charts/types.ts +++ b/web-common/src/features/components/charts/types.ts @@ -193,7 +193,7 @@ export interface CommonChartProperties { metrics_view: string; // Ad-hoc measures derived from existing measures via an arithmetic // expression; measure fields may name them. - ephemeral_measures?: EphemeralMeasureSpec[]; + calculated_measures?: EphemeralMeasureSpec[]; tooltip?: FieldConfig; vl_config?: string; } From 0a928dc9d08be102d291850688e05ad6c4b3a42b Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Sat, 5 Sep 2026 23:07:01 +0530 Subject: [PATCH 4/5] Label ephemeral measures as "Adhoc measure" in the UI User-facing labels, titles and URL-state errors now say "adhoc measure" instead of "calculated measure". Code identifiers and the canvas `calculated_measures` property are unchanged. Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf --- .../inspector/fields/FieldSelectorDropdown.svelte | 2 +- .../dashboards/ephemeral-measures/url-param.spec.ts | 2 +- .../stores/validate-and-clean-explore-state.ts | 2 +- .../url-state/convertLegacyStateToExplorePreset.ts | 2 +- .../dashboards/url-state/convertURLToExplorePreset.ts | 2 +- web-common/src/lib/i18n/messages/en.json | 10 +++++----- web-common/src/lib/i18n/messages/es.json | 10 +++++----- .../tests/explores/pivot-ephemeral-measures.spec.ts | 4 +--- 8 files changed, 16 insertions(+), 18 deletions(-) diff --git a/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte b/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte index 73c02aebded7..fa6f3559e48a 100644 --- a/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte +++ b/web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte @@ -16,7 +16,7 @@ export let types: FieldType[]; export let excludedValues: string[] | undefined = undefined; export let ephemeralMeasures: EphemeralMeasureDef[] | undefined = undefined; - // When set, the menu footer offers a "Create calculated measure" action. + // When set, the menu footer offers a "Create adhoc measure" action. export let onCreateEphemeral: (() => void) | undefined = undefined; // When set, ephemeral items get an edit button invoking this. export let onEditEphemeral: ((name: string) => void) | undefined = undefined; diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts index e6d7b3d180ea..776f473c2873 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts @@ -125,7 +125,7 @@ describe("ephemeral measures URL state integration", () => { "http://localhost/explore/AdBids_explore?view=pivot&cols=impressions,profit&ephemeral=profit:Profit:unknown*2", ); expect(errors.map((e) => e.message)).toEqual([ - `Selected calculated measure: "profit ("unknown" is not a measure in this dashboard)" is not valid.`, + `Selected adhoc measure: "profit ("unknown" is not a measure in this dashboard)" is not valid.`, `Selected pivot column: "profit" is not valid.`, ]); diff --git a/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts b/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts index 7fefbb7bf1da..1222e4c8a7eb 100644 --- a/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts +++ b/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts @@ -61,7 +61,7 @@ export function validateAndCleanExploreState( exploreState.ephemeralMeasures = valid.length ? valid : undefined; injectEphemeralMeasuresIntoMap(measures, valid); if (invalidEntries.length) { - errors.push(getMultiFieldError("calculated measure", invalidEntries)); + errors.push(getMultiFieldError("adhoc measure", invalidEntries)); } } diff --git a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts index f8e1adb91c85..801b9498ef5e 100644 --- a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts @@ -90,7 +90,7 @@ export function convertLegacyStateToExplorePreset( preset.ephemeralMeasures = toEphemeralMeasuresParam(valid); injectEphemeralMeasuresIntoMap(measures, valid); if (invalidEntries.length) { - errors.push(getMultiFieldError("calculated measure", invalidEntries)); + errors.push(getMultiFieldError("adhoc measure", invalidEntries)); } } diff --git a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts index fe54e1d5471b..ded4c62abfb8 100644 --- a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts @@ -131,7 +131,7 @@ export function convertURLToExplorePreset( preset.ephemeralMeasures = toEphemeralMeasuresParam(valid); injectEphemeralMeasuresIntoMap(measures, valid); if (invalidEntries.length) { - errors.push(getMultiFieldError("calculated measure", invalidEntries)); + errors.push(getMultiFieldError("adhoc measure", invalidEntries)); } } diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 91ee518f3171..cb727c8d728d 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -440,7 +440,7 @@ "canvas_max": "Max", "canvas_max_width": "Max width", "canvas_measure_label": "Measure", - "canvas_ephemeral_measures_label": "Calculated measures", + "canvas_ephemeral_measures_label": "Adhoc measures", "canvas_field_choose_field": "Choose a field...", "canvas_field_no_results": "no results", "canvas_field_time": "Time", @@ -817,12 +817,12 @@ "dashboard_pivot": "Pivot", "dashboard_pivot_add_measure": "Add a measure to complete your table.", "dashboard_pivot_conditional_formatting": "Conditional formatting", - "dashboard_pivot_ephemeral_measure": "Calculated measure", - "dashboard_pivot_ephemeral_create": "Create calculated measure", + "dashboard_pivot_ephemeral_measure": "Adhoc measure", + "dashboard_pivot_ephemeral_create": "Create adhoc measure", "dashboard_pivot_ephemeral_display_name_label": "Name", "dashboard_pivot_ephemeral_display_name_placeholder": "Profit", "dashboard_pivot_ephemeral_edit": "Edit", - "dashboard_pivot_ephemeral_edit_title": "Edit calculated measure", + "dashboard_pivot_ephemeral_edit_title": "Edit adhoc measure", "dashboard_pivot_ephemeral_expression_hint": "Combine existing measures with + - * / % and functions like round() or coalesce().", "dashboard_pivot_ephemeral_expression_label": "Expression", "dashboard_pivot_ephemeral_expression_placeholder": "e.g. revenue - cost", @@ -832,7 +832,7 @@ "dashboard_pivot_ephemeral_format_currency_eur": "Currency (EUR)", "dashboard_pivot_ephemeral_format_percentage": "Percentage", "dashboard_pivot_ephemeral_insert_measure": "Insert a measure:", - "dashboard_pivot_ephemeral_new_title": "New calculated measure", + "dashboard_pivot_ephemeral_new_title": "New adhoc measure", "dashboard_pivot_ephemeral_save": "Save", "dashboard_pivot_building_table": "Hang tight! We're building your table...", "dashboard_pivot_give_data": "Give it some data to keep it company.", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index bf77d3bdb0fa..a75d3c9a79a3 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -440,7 +440,7 @@ "canvas_max": "Máx", "canvas_max_width": "Ancho máximo", "canvas_measure_label": "Medida", - "canvas_ephemeral_measures_label": "Medidas calculadas", + "canvas_ephemeral_measures_label": "Medidas ad hoc", "canvas_field_choose_field": "Elige un campo...", "canvas_field_no_results": "sin resultados", "canvas_field_time": "Tiempo", @@ -817,12 +817,12 @@ "dashboard_pivot": "Pivote", "dashboard_pivot_add_measure": "Agrega una medida para completar tu tabla.", "dashboard_pivot_conditional_formatting": "Formato condicional", - "dashboard_pivot_ephemeral_measure": "Medida calculada", - "dashboard_pivot_ephemeral_create": "Crear medida calculada", + "dashboard_pivot_ephemeral_measure": "Medida ad hoc", + "dashboard_pivot_ephemeral_create": "Crear medida ad hoc", "dashboard_pivot_ephemeral_display_name_label": "Nombre", "dashboard_pivot_ephemeral_display_name_placeholder": "Beneficio", "dashboard_pivot_ephemeral_edit": "Editar", - "dashboard_pivot_ephemeral_edit_title": "Editar medida calculada", + "dashboard_pivot_ephemeral_edit_title": "Editar medida ad hoc", "dashboard_pivot_ephemeral_expression_hint": "Combina medidas existentes con + - * / % y funciones como round() o coalesce().", "dashboard_pivot_ephemeral_expression_label": "Expresión", "dashboard_pivot_ephemeral_expression_placeholder": "p. ej. revenue - cost", @@ -832,7 +832,7 @@ "dashboard_pivot_ephemeral_format_currency_eur": "Moneda (EUR)", "dashboard_pivot_ephemeral_format_percentage": "Porcentaje", "dashboard_pivot_ephemeral_insert_measure": "Insertar una medida:", - "dashboard_pivot_ephemeral_new_title": "Nueva medida calculada", + "dashboard_pivot_ephemeral_new_title": "Nueva medida ad hoc", "dashboard_pivot_ephemeral_save": "Guardar", "dashboard_pivot_building_table": "¡Un momento! Estamos construyendo tu tabla...", "dashboard_pivot_give_data": "Dale algunos datos para hacerle compañía.", diff --git a/web-local/tests/explores/pivot-ephemeral-measures.spec.ts b/web-local/tests/explores/pivot-ephemeral-measures.spec.ts index 11f0dabb0784..b005afb07818 100644 --- a/web-local/tests/explores/pivot-ephemeral-measures.spec.ts +++ b/web-local/tests/explores/pivot-ephemeral-measures.spec.ts @@ -82,9 +82,7 @@ test.describe("pivot ephemeral measures from URL state", () => { ); // The Measures section of the field sidebar offers a create CTA. - await page - .getByRole("button", { name: "Create calculated measure" }) - .click(); + await page.getByRole("button", { name: "Create adhoc measure" }).click(); await page.getByLabel("Name", { exact: true }).fill("Doubled"); await page From 95d4d4d57328acfff32081d0ae96fb0fb9be0282 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Sat, 5 Sep 2026 23:19:14 +0530 Subject: [PATCH 5/5] Address review findings for ephemeral measures - Canvas chart validation accepts the chart's own ephemeral measures. - The active (sort) measure resolves ephemeral measures so single-measure leaderboards format values instead of rendering blanks. - The client expression parser uses the server's depth limit (32). - "All measures visible" counts ephemeral measures, so a hidden spec measure is never reported as visible to public URLs, chat context or the editor. - Public URLs keep pivot chips of ephemeral measures that survive sanitizing. - Editing a scheduled report recovers ephemeral definitions from the saved request instead of a non-existent explore store. - Removing the last visible measure re-seeds spec defaults. - Aliases derived from display names always pass name validation (shared slugify), and URL-state validation reserves every metrics view field. Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf --- .../src/features/public-urls/form-utils.ts | 16 ++++----- .../canvas/components/charts/validate.ts | 7 +++- .../fields/EphemeralMeasureEditor.svelte | 23 +++---------- .../EphemeralMeasureDialog.svelte | 27 +++++---------- .../ephemeral-measures.spec.ts | 34 +++++++++++++++++++ .../expression-parser.spec.ts | 8 ++--- .../ephemeral-measures/expression-parser.ts | 2 +- .../ephemeral-measures/measure-mapping.ts | 21 ++++++++++++ .../ephemeral-measures/url-param.spec.ts | 23 +++++++++++++ .../ephemeral-measures/url-state.ts | 25 ++++++++++---- .../ephemeral-measures/validation.ts | 25 ++++++++++++++ .../dashboards/pivot/PivotDisplay.svelte | 6 +++- .../selectors/active-measure.ts | 12 ++++--- .../dashboards/stores/dashboard-stores.ts | 10 +++++- .../validate-and-clean-explore-state.ts | 7 ++-- .../convertLegacyStateToExplorePreset.ts | 2 +- .../url-state/convertPresetToExploreState.ts | 4 ++- .../url-state/convertURLToExplorePreset.ts | 3 +- .../ScheduledReportDialog.svelte | 8 ++++- 19 files changed, 190 insertions(+), 73 deletions(-) diff --git a/web-admin/src/features/public-urls/form-utils.ts b/web-admin/src/features/public-urls/form-utils.ts index 70ee27b2115d..8dfb9cff8788 100644 --- a/web-admin/src/features/public-urls/form-utils.ts +++ b/web-admin/src/features/public-urls/form-utils.ts @@ -92,6 +92,10 @@ export function getSanitizedExploreStateParam( const sanitizedEphemeralNames = new Set( sanitizedEphemeralMeasures?.map((def) => def.name) ?? [], ); + const isSharedPivotChip = (chip: { id: string; type: PivotChipType }) => + metricsViewFields.includes(chip.id) || + sanitizedEphemeralNames.has(chip.id) || + chip.type === PivotChipType.Time; const sanitizedDashboardState = { ephemeralMeasures: sanitizedEphemeralMeasures?.length ? sanitizedEphemeralMeasures @@ -143,16 +147,8 @@ export function getSanitizedExploreStateParam( tdd: exploreState.tdd, pivot: { ...exploreState.pivot, - rows: exploreState.pivot.rows.filter( - (chip) => - metricsViewFields?.includes(chip.id) || - chip.type === PivotChipType.Time, - ), - columns: exploreState.pivot.columns.filter( - (chip) => - metricsViewFields?.includes(chip.id) || - chip.type === PivotChipType.Time, - ), + rows: exploreState.pivot.rows.filter(isSharedPivotChip), + columns: exploreState.pivot.columns.filter(isSharedPivotChip), }, } as ExploreState; diff --git a/web-common/src/features/canvas/components/charts/validate.ts b/web-common/src/features/canvas/components/charts/validate.ts index a21387947d81..4c531bc94d1b 100644 --- a/web-common/src/features/canvas/components/charts/validate.ts +++ b/web-common/src/features/canvas/components/charts/validate.ts @@ -3,6 +3,7 @@ import { validateDimensions, validateMeasures, } from "@rilldata/web-common/features/canvas/components/validators"; +import { chartEphemeralMeasureNames } from "@rilldata/web-common/features/components/charts/ephemeral-measures"; import { getFieldsByType } from "@rilldata/web-common/features/components/charts/util"; import type { MetricsViewSelectors } from "@rilldata/web-common/features/metrics-views/metrics-view-selectors"; import { derived, type Readable } from "svelte/store"; @@ -45,7 +46,11 @@ export function validateChartSchema( }; } - const validateMeasuresRes = validateMeasures(metricsView, measures); + const validateMeasuresRes = validateMeasures( + metricsView, + measures, + chartEphemeralMeasureNames(chartSpec), + ); if (!validateMeasuresRes.isValid) { const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", "); return { diff --git a/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte b/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte index 46c2e8571029..dc7b81e96e60 100644 --- a/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte +++ b/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte @@ -17,8 +17,8 @@ } from "@rilldata/web-common/features/dashboards/ephemeral-measures/expression-parser"; import type { EphemeralMeasureDef } from "@rilldata/web-common/features/dashboards/ephemeral-measures/types"; import { - EPHEMERAL_MEASURE_NAME_REGEX, isReferenceableMeasure, + slugifyEphemeralMeasureName, validateEphemeralMeasureDef, } from "@rilldata/web-common/features/dashboards/ephemeral-measures/validation"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; @@ -71,7 +71,7 @@ ); $: reservedNames = new Set([ - ...knownMeasureNames, + ...(metricsViewSpec?.measures ?? []).map((mes) => mes.name as string), ...(metricsViewSpec?.dimensions ?? []).map( (d) => (d.name || d.column) as string, ), @@ -105,21 +105,6 @@ ); } - function slugify(name: string): string { - let slug = name - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, ""); - if (!EPHEMERAL_MEASURE_NAME_REGEX.test(slug)) slug = `m_${slug}`; - let candidate = slug; - let i = 2; - while (reservedNames.has(candidate)) { - candidate = `${slug}_${i}`; - i++; - } - return candidate; - } - function insertMeasure(name: string) { const token = formatMeasureRef(name); expression = expression === "" ? token : `${expression} ${token}`; @@ -127,7 +112,9 @@ function save() { const def: EphemeralMeasureDef = { - name: editingDef?.name ?? slugify(displayName), + name: + editingDef?.name ?? + slugifyEphemeralMeasureName(displayName, reservedNames), displayName: displayName.trim(), expression: expression.trim(), ...(formatPreset !== FormatPreset.HUMANIZE ? { formatPreset } : {}), diff --git a/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte index 87c9b44de8eb..ce17e8984462 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte +++ b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte @@ -16,8 +16,8 @@ parseMeasureExpression, } from "./expression-parser"; import { - EPHEMERAL_MEASURE_NAME_REGEX, isReferenceableMeasure, + slugifyEphemeralMeasureName, validateEphemeralMeasureDef, } from "./validation"; @@ -71,21 +71,6 @@ ? `"${unknownRef}" is not a measure in this dashboard` : undefined)); - function slugify(label: string): string { - let slug = label - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, ""); - if (!EPHEMERAL_MEASURE_NAME_REGEX.test(slug)) slug = `m_${slug}`; - let candidate = slug; - let i = 2; - while (reservedNames.has(candidate)) { - candidate = `${slug}_${i}`; - i++; - } - return candidate; - } - function insertMeasure(name: string) { const token = formatMeasureRef(name); expression = expression === "" ? token : `${expression} ${token}`; @@ -97,7 +82,9 @@ function save() { const def: EphemeralMeasureDef = { - name: editingDef?.name ?? slugify(displayName), + name: + editingDef?.name ?? + slugifyEphemeralMeasureName(displayName, reservedNames), displayName: displayName.trim(), expression: expression.trim(), ...(formatPreset !== FormatPreset.HUMANIZE ? { formatPreset } : {}), @@ -119,7 +106,11 @@ function remove() { if (!editingDef) return; - metricsExplorerStore.removeEphemeralMeasure($exploreName, editingDef.name); + metricsExplorerStore.removeEphemeralMeasure( + $exploreName, + editingDef.name, + explore, + ); close(); } diff --git a/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts index d4e7304d10d3..7567cb360215 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/ephemeral-measures.spec.ts @@ -3,6 +3,7 @@ import type { V1MetricsViewAggregationMeasure } from "@rilldata/web-common/runti import { prepareMeasuresForRequest } from "../pivot/pivot-utils"; import type { EphemeralMeasureDef } from "./types"; import { + slugifyEphemeralMeasureName, validateEphemeralMeasureDef, validateEphemeralMeasureName, } from "./validation"; @@ -122,3 +123,36 @@ describe("validateEphemeralMeasureDef", () => { ); }); }); + +describe("slugifyEphemeralMeasureName", () => { + const reserved = new Set(["profit", "revenue", "cost"]); + + it("derives a plain alias from the display name", () => { + expect(slugifyEphemeralMeasureName("Gross Margin", reserved)).toBe( + "gross_margin", + ); + }); + + it("disambiguates collisions with existing fields", () => { + expect(slugifyEphemeralMeasureName("Profit", reserved)).toBe("profit_2"); + }); + + it("always yields an alias that passes name validation", () => { + for (const label of [ + "Revenue Delta", + "Cost Prev", + "Revenue Percent Of Total", + "Total Rill Value", + "a__previous", + "123 abc", + "Profit", + ]) { + const slug = slugifyEphemeralMeasureName(label, reserved); + expect(validateEphemeralMeasureName(slug, reserved)).toBeUndefined(); + } + expect(slugifyEphemeralMeasureName("Revenue Delta", reserved)).toBe( + "revenue_delta_2", + ); + expect(slugifyEphemeralMeasureName("123 abc", reserved)).toBe("m_123_abc"); + }); +}); diff --git a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts index a7a204a17e59..815b8e508be0 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.spec.ts @@ -93,12 +93,12 @@ describe("parseMeasureExpression", () => { expect(res.error!.message).toContain("deeply nested"); }); - // Mirrors the server parser, which fails flat chains of more than 64 - // binary operators with "too deeply nested". + // Mirrors the server parser (maxMeasureExpressionDepth = 32), which fails + // flat chains of more than 32 binary operators with "too deeply nested". it("limits total expression complexity like the server", () => { - const ok = Array.from({ length: 65 }, (_, i) => `a${i}`).join("+"); + const ok = Array.from({ length: 33 }, (_, i) => `a${i}`).join("+"); expect(parseMeasureExpression(ok).error).toBeUndefined(); - const tooLong = Array.from({ length: 66 }, (_, i) => `a${i}`).join("+"); + const tooLong = Array.from({ length: 34 }, (_, i) => `a${i}`).join("+"); expect(parseMeasureExpression(tooLong).error?.message).toContain( "deeply nested", ); diff --git a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts index 34cab49a4adb..9fc58ed17457 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts @@ -34,7 +34,7 @@ export const EPHEMERAL_MEASURE_FUNCTIONS: Record< // Keep in sync with the limits in runtime/metricsview/measure_expression.go. export const MAX_EPHEMERAL_EXPRESSION_LENGTH = 1024; -const MAX_DEPTH = 64; +const MAX_DEPTH = 32; // SQL words the server-side parser treats as reserved: a measure with one of // these names must be double-quoted in expressions. Generated by probing the diff --git a/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts b/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts index 69baab353076..88221a844589 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts @@ -106,6 +106,27 @@ export function splitTimeSeriesMeasures( }; } +/** + * Recovers ephemeral measure definitions from request measures that carry an + * `expression` compute, e.g. when editing a saved report outside an explore. + */ +export function ephemeralDefsFromRequestMeasures( + measures: V1MetricsViewAggregationMeasure[] | undefined, +): EphemeralMeasureDef[] | undefined { + const defs = (measures ?? []).flatMap((measure) => + measure.name && measure.expression?.expression + ? [ + { + name: measure.name, + displayName: measure.expression.displayName || measure.name, + expression: measure.expression.expression, + }, + ] + : [], + ); + return defs.length ? defs : undefined; +} + /** * Returns the set of ephemeral measure names, for quick membership checks. */ diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts index 776f473c2873..9928c17000f4 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/url-param.spec.ts @@ -136,6 +136,29 @@ describe("ephemeral measures URL state integration", () => { ]); }); + it("drops definitions named after a measure hidden from the explore", () => { + // publisher_count exists in the metrics view but not in the explore, and + // the server rejects computed fields that collide with any measure. + const errors = applyUrl( + "http://localhost/explore/AdBids_explore?view=pivot&cols=publisher_count&ephemeral=publisher_count:PC:impressions*2", + ); + expect(errors.length).toBe(2); + expect(errors[0].message).toContain("already used by another field"); + expect( + getCleanMetricsExploreForAssertion().ephemeralMeasures, + ).toBeUndefined(); + }); + + it("does not report all measures visible while a spec measure is hidden", () => { + const errors = applyUrl( + "http://localhost/explore/AdBids_explore?measures=impressions,profit&ephemeral=profit:Profit:impressions*2", + ); + expect(errors).toEqual([]); + const state = getCleanMetricsExploreForAssertion(); + expect(state.visibleMeasures).toEqual(["impressions", "profit"]); + expect(state.allMeasuresVisible).toBe(false); + }); + it("drops definitions whose name collides with a metrics view field", () => { const errors = applyUrl( "http://localhost/explore/AdBids_explore?view=pivot&cols=bid_price&ephemeral=bid_price:Custom:impressions*2", diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-state.ts b/web-common/src/features/dashboards/ephemeral-measures/url-state.ts index 6ec1f11228f8..11b692207435 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/url-state.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/url-state.ts @@ -1,6 +1,6 @@ import type { - MetricsViewSpecDimension, MetricsViewSpecMeasure, + V1MetricsViewSpec, } from "@rilldata/web-common/runtime-client"; import { ephemeralMeasureToSpecMeasure } from "./measure-mapping"; import { fromEphemeralMeasuresParam } from "./url-param"; @@ -11,21 +11,30 @@ import { } from "./validation"; /** - * Validates ephemeral measure definitions against the metrics view / explore - * field maps used during URL state conversion. Returns the valid definitions + * Validates ephemeral measure definitions during URL state conversion. + * `measures` is the explore's measure map (what an expression may reference); + * names are reserved against every field of the metrics view, matching the + * server's collision check, so a definition can never shadow a hidden + * measure, a dimension or the time dimension. Returns the valid definitions * and human-readable labels for the dropped ones. */ export function validateEphemeralDefsAgainstSpec( defs: EphemeralMeasureDef[], measures: Map, - dimensions: Map, + metricsView: V1MetricsViewSpec, ): { valid: EphemeralMeasureDef[]; invalidEntries: string[] } { const knownMeasureNames = new Set( [...measures.values()] .filter(isReferenceableMeasure) .map((m) => m.name as string), ); - const reservedNames = new Set([...measures.keys(), ...dimensions.keys()]); + const reservedNames = new Set([ + ...(metricsView.measures ?? []).map((m) => m.name as string), + ...(metricsView.dimensions ?? []).map( + (d) => (d.name || d.column) as string, + ), + ...(metricsView.timeDimension ? [metricsView.timeDimension] : []), + ]); const valid: EphemeralMeasureDef[] = []; const invalidEntries: string[] = []; for (const def of defs) { @@ -38,6 +47,8 @@ export function validateEphemeralDefsAgainstSpec( invalidEntries.push(`${def.name} (${error})`); } else { valid.push(def); + // Later definitions may not reuse this name either. + reservedNames.add(def.name); } } return { valid, invalidEntries }; @@ -49,14 +60,14 @@ export function validateEphemeralDefsAgainstSpec( export function parseAndValidateEphemeralParam( param: string, measures: Map, - dimensions: Map, + metricsView: V1MetricsViewSpec, ): { valid: EphemeralMeasureDef[]; invalidEntries: string[] } { const { ephemeralMeasures, invalidEntries } = fromEphemeralMeasuresParam(param); const res = validateEphemeralDefsAgainstSpec( ephemeralMeasures, measures, - dimensions, + metricsView, ); return { valid: res.valid, diff --git a/web-common/src/features/dashboards/ephemeral-measures/validation.ts b/web-common/src/features/dashboards/ephemeral-measures/validation.ts index 0d3d41a93f8d..0447aeb71219 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/validation.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/validation.ts @@ -68,6 +68,31 @@ export function validateEphemeralMeasureName( return undefined; } +/** + * Derives a query alias from a display name that passes + * `validateEphemeralMeasureName`: reserved suffixes and collisions are + * resolved with a numeric suffix, so a valid display name is always saveable. + */ +export function slugifyEphemeralMeasureName( + label: string, + reservedNames: Set, +): string { + let slug = label + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + // Collapse runs of underscores so the `__previous`-style pivot suffixes + // can never appear. + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + if (!EPHEMERAL_MEASURE_NAME_REGEX.test(slug)) slug = `m_${slug}`; + while (slug.includes("_rill_")) slug = slug.replace("_rill_", "_rill"); + let candidate = slug; + for (let i = 2; validateEphemeralMeasureName(candidate, reservedNames); i++) { + candidate = `${slug}_${i}`; + } + return candidate; +} + /** * Validates a full ephemeral measure definition. * `knownMeasureNames` are the measure names an expression may reference diff --git a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte index a8ff8a12d34b..4c5769827d5a 100644 --- a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte +++ b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte @@ -180,7 +180,11 @@ if (def) ephemeralMeasureDialog.set({ def }); }} onDeleteEphemeralMeasure={(id) => - metricsExplorerStore.removeEphemeralMeasure($exploreName, id)} + metricsExplorerStore.removeEphemeralMeasure( + $exploreName, + id, + $validSpecStore.data?.explore, + )} /> {/if}
measure.name === activeMeasureName(dashData), - ); - return activeMeasure; + // Ephemeral measures can be the sort measure too; they have no spec entry, + // so synthesize one for formatters and tooltips. + const name = activeMeasureName(dashData); + return appendEphemeralSpecMeasures( + dashData.validMetricsView.measures, + dashData.dashboard.ephemeralMeasures, + ).find((measure) => measure.name === name); }; export const activeMeasureName = (dashData: DashboardDataSources): string => { diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index be3376e2c272..da4a32ac6c58 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -702,7 +702,11 @@ const metricsViewReducers = { }); }, - removeEphemeralMeasure(name: string, measureName: string) { + removeEphemeralMeasure( + name: string, + measureName: string, + explore: V1ExploreSpec | undefined, + ) { updateMetricsExplorerByName(name, (exploreState) => { exploreState.ephemeralMeasures = ( exploreState.ephemeralMeasures ?? [] @@ -744,6 +748,10 @@ const metricsViewReducers = { exploreState.activePage = DashboardState_ActivePage.DEFAULT; } } + + // The removed measure may have been the only visible or leaderboard + // measure; restore the spec defaults so views never end up measure-less. + if (explore) syncMeasures(explore, exploreState); }); }, diff --git a/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts b/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts index 1222e4c8a7eb..7a4c6d7a203c 100644 --- a/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts +++ b/web-common/src/features/dashboards/stores/validate-and-clean-explore-state.ts @@ -56,7 +56,7 @@ export function validateAndCleanExploreState( const { valid, invalidEntries } = validateEphemeralDefsAgainstSpec( exploreState.ephemeralMeasures, measures, - dimensions, + metricsViewSpec, ); exploreState.ephemeralMeasures = valid.length ? valid : undefined; injectEphemeralMeasuresIntoMap(measures, valid); @@ -163,8 +163,9 @@ function validateAndCleanMeasureRelatedExploreState( if (selectedMeasures.length > 0) { // If there are any remaining valid measures then set it. - exploreState.allMeasuresVisible = - selectedMeasures.length === exploreSpec.measures?.length; + // `measures` includes ephemeral measures, so a hidden spec measure can + // never be masked by a visible ephemeral one. + exploreState.allMeasuresVisible = selectedMeasures.length === measures.size; exploreState.visibleMeasures = selectedMeasures; } else { // Else remove the relevant fields so that cascading merge can set fields from other sources. diff --git a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts index 801b9498ef5e..eecad38263c2 100644 --- a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts @@ -85,7 +85,7 @@ export function convertLegacyStateToExplorePreset( ...(def.formatPreset ? { formatPreset: def.formatPreset } : {}), })), measures, - dimensions, + metricsView, ); preset.ephemeralMeasures = toEphemeralMeasuresParam(valid); injectEphemeralMeasuresIntoMap(measures, valid); diff --git a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts index 344f84639db4..2492d205e4d0 100644 --- a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts +++ b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts @@ -265,8 +265,10 @@ function fromExploreUrlParams( errors.push(getMultiFieldError("measure", missingMeasures)); } + // `measures` includes ephemeral measures, so a hidden spec measure can + // never be masked by a visible ephemeral one. partialExploreState.allMeasuresVisible = - selectedMeasures.length === explore.measures?.length; + selectedMeasures.length === measures.size; partialExploreState.visibleMeasures = [...selectedMeasures]; } diff --git a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts index ded4c62abfb8..35a5e0cd94d1 100644 --- a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts @@ -125,8 +125,7 @@ export function convertURLToExplorePreset( const { valid, invalidEntries } = parseAndValidateEphemeralParam( ephemeralParam, measures, - // Includes time dimensions so a definition cannot shadow one. - allDimensions, + metricsView, ); preset.ephemeralMeasures = toEphemeralMeasuresParam(valid); injectEphemeralMeasuresIntoMap(measures, valid); diff --git a/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte b/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte index 4eca920c1c15..4b641862a024 100644 --- a/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte +++ b/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte @@ -18,6 +18,7 @@