diff --git a/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte b/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte
index a0d38217d2f..d1aed55b458 100644
--- a/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte
+++ b/web-common/src/features/canvas/inspector/fields/EphemeralMeasureEditor.svelte
@@ -1,8 +1,7 @@
+
- {
- 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/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte
index 60dd5767a67..6cbd9a97e11 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte
+++ b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureDialog.svelte
@@ -1,40 +1,21 @@
+
- {
- 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/EphemeralMeasureForm.svelte b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureForm.svelte
new file mode 100644
index 00000000000..e32aa02c90d
--- /dev/null
+++ b/web-common/src/features/dashboards/ephemeral-measures/EphemeralMeasureForm.svelte
@@ -0,0 +1,208 @@
+
+
+
+ {
+ 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 onRemove}
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/web-common/src/features/dashboards/ephemeral-measures/MeasureExpressionInput.svelte b/web-common/src/features/dashboards/ephemeral-measures/MeasureExpressionInput.svelte
new file mode 100644
index 00000000000..fc7f3863271
--- /dev/null
+++ b/web-common/src/features/dashboards/ephemeral-measures/MeasureExpressionInput.svelte
@@ -0,0 +1,360 @@
+
+
+
+
+
+
+
+
+
+
+ {#if shownErrors}
+
{shownErrors}
+ {/if}
+
+ {#if open}
+
+
+ {#each options as measure, i (measure.name)}
+
+ {/each}
+
+
+
+
+
+ {m.dashboard_pivot_ephemeral_mention_navigate()}
+
+ Enter
+ {m.dashboard_pivot_ephemeral_mention_select()}
+
+
+
+ {/if}
+
+
+
diff --git a/web-common/src/features/dashboards/ephemeral-measures/canvas.ts b/web-common/src/features/dashboards/ephemeral-measures/canvas.ts
index 20fe0059460..ca9dd160af3 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/canvas.ts
+++ b/web-common/src/features/dashboards/ephemeral-measures/canvas.ts
@@ -6,6 +6,7 @@ export interface EphemeralMeasureSpec {
display_name: string;
expression: string;
format_preset?: string;
+ description?: string;
}
export function ephemeralSpecsToDefs(
@@ -19,6 +20,7 @@ export function ephemeralSpecsToDefs(
displayName: spec.display_name || spec.name,
expression: spec.expression,
...(spec.format_preset ? { formatPreset: spec.format_preset } : {}),
+ ...(spec.description ? { description: spec.description } : {}),
}));
}
@@ -30,6 +32,7 @@ export function ephemeralDefsToSpecs(
display_name: def.displayName,
expression: def.expression,
...(def.formatPreset ? { format_preset: def.formatPreset } : {}),
+ ...(def.description ? { description: def.description } : {}),
}));
}
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 9681f1cd036..fdc4b458e87 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts
+++ b/web-common/src/features/dashboards/ephemeral-measures/expression-parser.ts
@@ -297,9 +297,16 @@ export type MeasureExpressionError = {
position: number;
};
+// A measure reference in the expression: `start` is the 0-based offset of its
+// first character (the opening quote for quoted names) and `end` the offset
+// after its last one.
+export type MeasureRefSpan = { name: string; start: number; end: number };
+
export type MeasureExpressionParseResult = {
// Referenced identifiers, deduplicated in order of first appearance.
refs: string[];
+ // Every reference in order of appearance, for editors that decorate them.
+ refSpans: MeasureRefSpan[];
error?: MeasureExpressionError;
};
@@ -335,11 +342,16 @@ export function parseMeasureExpression(
expression: string,
): MeasureExpressionParseResult {
if (expression.trim() === "") {
- return { refs: [], error: { message: "expression is empty", position: 0 } };
+ return {
+ refs: [],
+ refSpans: [],
+ error: { message: "expression is empty", position: 0 },
+ };
}
if (expression.length > MAX_EPHEMERAL_EXPRESSION_LENGTH) {
return {
refs: [],
+ refSpans: [],
error: {
message: `expression exceeds the maximum length of ${MAX_EPHEMERAL_EXPRESSION_LENGTH} characters`,
position: MAX_EPHEMERAL_EXPRESSION_LENGTH,
@@ -347,21 +359,27 @@ export function parseMeasureExpression(
};
}
try {
- const refs: string[] = [];
- walk(parse(expression), 0, refs);
+ const refSpans: MeasureRefSpan[] = [];
+ walk(parse(expression), 0, refSpans);
+ const refs = [...new Set(refSpans.map((span) => span.name))];
if (refs.length === 0) {
return {
refs: [],
+ refSpans: [],
error: {
message: "expression must reference at least one measure",
position: 0,
},
};
}
- return { refs };
+ return { refs, refSpans };
} catch (e) {
if (e instanceof ParseError) {
- return { refs: [], error: { message: e.message, position: e.position } };
+ return {
+ refs: [],
+ refSpans: [],
+ error: { message: e.message, position: e.position },
+ };
}
throw e;
}
@@ -496,12 +514,12 @@ function partialNumberError(
}
/**
- * Validates the parsed tree and collects referenced measure names, in order of
- * first appearance. Mirrors the server's `parseNode`: every node, including
+ * Validates the parsed tree and collects the measure references in order of
+ * appearance. Mirrors the server's `parseNode`: every node, including
* parentheses, counts one level toward the nesting limit, so a flat chain of
* binary operators is bounded as well as deep nesting.
*/
-function walk(node: Node, depth: number, refs: string[]): void {
+function walk(node: Node, depth: number, refs: MeasureRefSpan[]): void {
if (depth > MAX_DEPTH) {
throw new ParseError("expression is too deeply nested", node.pos);
}
@@ -509,11 +527,22 @@ function walk(node: Node, depth: number, refs: string[]): void {
case "literal":
return;
case "ref":
- addRef(refs, node.name);
+ // Quoted: the name plus its quotes, with inner quotes doubled.
+ refs.push({
+ name: node.name,
+ start: node.pos,
+ end: node.pos + node.name.replace(/"/g, '""').length + 2,
+ });
return;
case "word":
// Bare true/false/null parse as literals server-side.
- if (!LITERAL_WORDS.has(node.name.toLowerCase())) addRef(refs, node.name);
+ if (!LITERAL_WORDS.has(node.name.toLowerCase())) {
+ refs.push({
+ name: node.name,
+ start: node.pos,
+ end: node.pos + node.name.length,
+ });
+ }
return;
case "func": {
const spec = EPHEMERAL_MEASURE_FUNCTIONS[node.name.toLowerCase()];
@@ -543,7 +572,3 @@ function walk(node: Node, depth: number, refs: string[]): void {
return;
}
}
-
-function addRef(refs: string[], name: string): void {
- if (!refs.includes(name)) refs.push(name);
-}
diff --git a/web-common/src/features/dashboards/ephemeral-measures/library.ts b/web-common/src/features/dashboards/ephemeral-measures/library.ts
index 982bfe87929..1c235d0d306 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/library.ts
+++ b/web-common/src/features/dashboards/ephemeral-measures/library.ts
@@ -156,6 +156,7 @@ function isEphemeralMeasureDef(value: unknown): value is EphemeralMeasureDef {
!!def.displayName &&
typeof def.expression === "string" &&
!!def.expression &&
- (def.formatPreset === undefined || typeof def.formatPreset === "string")
+ (def.formatPreset === undefined || typeof def.formatPreset === "string") &&
+ (def.description === undefined || typeof def.description === "string")
);
}
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 88221a84458..525c6411e2a 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts
+++ b/web-common/src/features/dashboards/ephemeral-measures/measure-mapping.ts
@@ -17,8 +17,9 @@ export function ephemeralMeasureToSpecMeasure(
name: def.name,
displayName: def.displayName,
expression: def.expression,
- // Surfaces the calculation in description-driven tooltips.
- description: def.expression,
+ // Description-driven tooltips show the calculation unless the user
+ // described the measure.
+ description: def.description || def.expression,
formatPreset: def.formatPreset ?? (FormatPreset.HUMANIZE as string),
};
}
diff --git a/web-common/src/features/dashboards/ephemeral-measures/measure-mention.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/measure-mention.spec.ts
new file mode 100644
index 00000000000..3c95eefaf93
--- /dev/null
+++ b/web-common/src/features/dashboards/ephemeral-measures/measure-mention.spec.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vitest";
+import {
+ filterMeasureMentions,
+ serializeExpressionTokens,
+ tokenizeMeasureExpression,
+} from "./measure-mention";
+
+const measures = [
+ { name: "total_revenue", displayName: "Total Revenue" },
+ { name: "total_cost", displayName: "Total Cost" },
+ { name: "net_rev", displayName: "Net Revenue" },
+ { name: "margin" },
+ { name: "select", displayName: "Select" },
+];
+
+describe("filterMeasureMentions", () => {
+ it("returns everything for an empty query", () => {
+ expect(filterMeasureMentions(measures, "")).toEqual(measures);
+ expect(filterMeasureMentions(measures, " ")).toEqual(measures);
+ });
+
+ it("matches display names and names case-insensitively", () => {
+ expect(
+ filterMeasureMentions(measures, "REV").map((mes) => mes.name),
+ ).toEqual(["total_revenue", "net_rev"]);
+ expect(
+ filterMeasureMentions(measures, "net rev ").map((mes) => mes.name),
+ ).toEqual(["net_rev"]);
+ expect(
+ filterMeasureMentions(measures, "marg").map((mes) => mes.name),
+ ).toEqual(["margin"]);
+ expect(filterMeasureMentions(measures, "profit")).toEqual([]);
+ });
+});
+
+describe("tokenizeMeasureExpression", () => {
+ it("turns known measures into chips and keeps the rest as text", () => {
+ expect(
+ tokenizeMeasureExpression("total_revenue - total_cost * 2", measures),
+ ).toEqual([
+ { type: "measure", name: "total_revenue", displayName: "Total Revenue" },
+ { type: "text", text: " - " },
+ { type: "measure", name: "total_cost", displayName: "Total Cost" },
+ { type: "text", text: " * 2" },
+ ]);
+ });
+
+ it("keeps functions, literals and unknown names as text", () => {
+ expect(
+ tokenizeMeasureExpression(
+ "round(margin / unknown, 2) + null + abs(-1e3)",
+ measures,
+ ),
+ ).toEqual([
+ { type: "text", text: "round(" },
+ { type: "measure", name: "margin", displayName: "margin" },
+ { type: "text", text: " / unknown, 2) + null + abs(-1e3)" },
+ ]);
+ // A function named like a measure is still a function call.
+ expect(
+ tokenizeMeasureExpression("round(1) + margin_2", [
+ ...measures,
+ { name: "round" },
+ { name: "margin_2" },
+ ]),
+ ).toEqual([
+ { type: "text", text: "round(1) + " },
+ { type: "measure", name: "margin_2", displayName: "margin_2" },
+ ]);
+ });
+
+ it("keeps an expression that does not parse as text", () => {
+ expect(tokenizeMeasureExpression("margin +", measures)).toEqual([
+ { type: "text", text: "margin +" },
+ ]);
+ expect(tokenizeMeasureExpression("margin @", measures)).toEqual([
+ { type: "text", text: "margin @" },
+ ]);
+ });
+
+ it("recognizes quoted references", () => {
+ expect(tokenizeMeasureExpression('"select" + "net_rev"', measures)).toEqual(
+ [
+ { type: "measure", name: "select", displayName: "Select" },
+ { type: "text", text: " + " },
+ { type: "measure", name: "net_rev", displayName: "Net Revenue" },
+ ],
+ );
+ });
+
+ it("returns a single text token when nothing matches", () => {
+ expect(tokenizeMeasureExpression("a + b", measures)).toEqual([
+ { type: "text", text: "a + b" },
+ ]);
+ expect(tokenizeMeasureExpression("", measures)).toEqual([]);
+ });
+});
+
+describe("serializeExpressionTokens", () => {
+ it("round trips an expression, quoting names that need it", () => {
+ const expression = 'total_revenue - "select" * (2 + net_rev)';
+ expect(
+ serializeExpressionTokens(
+ tokenizeMeasureExpression(expression, measures),
+ ),
+ ).toBe(expression);
+ expect(
+ serializeExpressionTokens([
+ { type: "measure", name: "select", displayName: "Select" },
+ { type: "text", text: " " },
+ ]),
+ ).toBe('"select" ');
+ });
+});
diff --git a/web-common/src/features/dashboards/ephemeral-measures/measure-mention.ts b/web-common/src/features/dashboards/ephemeral-measures/measure-mention.ts
new file mode 100644
index 00000000000..7acd0d45b71
--- /dev/null
+++ b/web-common/src/features/dashboards/ephemeral-measures/measure-mention.ts
@@ -0,0 +1,85 @@
+/**
+ * Helpers for the "@" measure picker in the ephemeral measure expression
+ * editor (MeasureExpressionInput.svelte).
+ *
+ * Typing "@" in the expression opens a picker of the referenceable measures,
+ * searchable by display name as well as by name, so users do not need to know
+ * a measure's field name to reference it. A picked measure is shown as a chip
+ * with its display name, like a mention in the AI chat, and serializes to its
+ * (quoted if needed) name in the expression.
+ */
+import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client";
+import { formatMeasureRef, parseMeasureExpression } from "./expression-parser";
+
+// A "@" only starts a mention when it does not directly follow an identifier or
+// a quoted name, so "@" pasted in the middle of a name is not picked up.
+export const IDENTIFIER_CHAR = /[A-Za-z0-9_"]/;
+
+export type ExpressionToken =
+ | { type: "text"; text: string }
+ | { type: "measure"; name: string; displayName: string };
+
+/**
+ * Splits an expression into plain text and references to the given measures,
+ * so an existing expression can be shown with measure chips. References are
+ * located by the expression parser; names that are not a known measure stay
+ * text, and so does the whole expression when it does not parse.
+ */
+export function tokenizeMeasureExpression(
+ expression: string,
+ measures: MetricsViewSpecMeasure[],
+): ExpressionToken[] {
+ const byName = new Map(measures.map((mes) => [mes.name ?? "", mes]));
+ const tokens: ExpressionToken[] = [];
+ let last = 0;
+ const pushText = (text: string) => {
+ if (!text) return;
+ const prev = tokens[tokens.length - 1];
+ if (prev?.type === "text") {
+ prev.text += text;
+ } else {
+ tokens.push({ type: "text", text });
+ }
+ };
+ for (const span of parseMeasureExpression(expression).refSpans) {
+ const measure = byName.get(span.name);
+ if (!measure) continue;
+ pushText(expression.slice(last, span.start));
+ tokens.push({
+ type: "measure",
+ name: span.name,
+ displayName: measure.displayName || span.name,
+ });
+ last = span.end;
+ }
+ pushText(expression.slice(last));
+ return tokens;
+}
+
+/**
+ * Returns the measures matching the mention query by display name or name,
+ * keeping the metrics view's order. An empty query matches everything.
+ */
+export function filterMeasureMentions(
+ measures: MetricsViewSpecMeasure[],
+ query: string,
+): MetricsViewSpecMeasure[] {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return measures;
+ return measures.filter(
+ (mes) =>
+ (mes.displayName ?? "").toLowerCase().includes(needle) ||
+ (mes.name ?? "").toLowerCase().includes(needle),
+ );
+}
+
+/**
+ * Serializes the editor's tokens back to an expression.
+ */
+export function serializeExpressionTokens(tokens: ExpressionToken[]): string {
+ return tokens
+ .map((token) =>
+ token.type === "text" ? token.text : formatMeasureRef(token.name),
+ )
+ .join("");
+}
diff --git a/web-common/src/features/dashboards/ephemeral-measures/types.ts b/web-common/src/features/dashboards/ephemeral-measures/types.ts
index a85096a263a..7029a6d0233 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/types.ts
+++ b/web-common/src/features/dashboards/ephemeral-measures/types.ts
@@ -13,6 +13,8 @@ export type EphemeralMeasureDef = {
displayName: string;
// Arithmetic expression over existing measure names, e.g. "revenue - cost".
expression: string;
+ // Optional description shown in tooltips instead of the expression.
+ description?: 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
index 24fe974627e..4d1633ae8ae 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
@@ -42,6 +42,22 @@ describe("ephemeral url param", () => {
expression: "round(revenue / users, 2)",
},
],
+ // A description with and without a format preset.
+ [
+ {
+ name: "margin",
+ displayName: "Margin",
+ expression: "profit / revenue",
+ description: "Profit per dollar: of revenue; net",
+ },
+ {
+ name: "arpu",
+ displayName: "ARPU",
+ expression: "revenue / users",
+ formatPreset: "currency_usd",
+ description: "Average revenue per user",
+ },
+ ],
// Hostile characters in display names and expressions.
[
{
diff --git a/web-common/src/features/dashboards/ephemeral-measures/url-param.ts b/web-common/src/features/dashboards/ephemeral-measures/url-param.ts
index 4d0a97414a5..dc523124bb6 100644
--- a/web-common/src/features/dashboards/ephemeral-measures/url-param.ts
+++ b/web-common/src/features/dashboards/ephemeral-measures/url-param.ts
@@ -7,12 +7,14 @@ import { MAX_EPHEMERAL_MEASURES } from "./validation";
*
* Grammar:
* param := entry (";" entry)*
- * entry := name ":" enc(displayName) ":" enc(expression) [":" formatPreset]
+ * entry := name ":" enc(displayName) ":" enc(expression)
+ * [":" formatPreset [":" enc(description)]]
*
* `enc` is encodeURIComponent applied per field: it encodes ":" and ";" so
* splitting is unambiguous. `name` is a validated slug and needs no encoding.
+ * `formatPreset` is left empty when only a description follows.
*
- * Example: `profit:Profit:revenue%20-%20cost;arpu:ARPU:revenue%20%2F%20users:currency_usd`
+ * Example: `profit:Profit:revenue%20-%20cost;arpu:ARPU:revenue%20%2F%20users:currency_usd;m:Margin:profit%20%2F%20revenue::Profit%20per%20dollar`
*/
export function toEphemeralMeasuresParam(
@@ -28,7 +30,10 @@ export function toEphemeralMeasuresParam(
encodeURIComponent(def.displayName),
encodeURIComponent(def.expression),
];
- if (def.formatPreset) parts.push(def.formatPreset);
+ if (def.formatPreset || def.description) {
+ parts.push(def.formatPreset ?? "");
+ }
+ if (def.description) parts.push(encodeURIComponent(def.description));
return parts.join(":");
})
.join(";");
@@ -45,16 +50,21 @@ export function fromEphemeralMeasuresParam(param: string): {
for (const entry of param.split(";")) {
if (!entry) continue;
const parts = entry.split(":");
- if (parts.length < 3 || parts.length > 4) {
+ if (parts.length < 3 || parts.length > 5) {
invalidEntries.push(entry);
continue;
}
- const [name, encDisplayName, encExpression, formatPreset] = parts;
+ const [name, encDisplayName, encExpression, formatPreset, encDescription] =
+ parts;
let displayName: string;
let expression: string;
+ let description: string | undefined;
try {
displayName = decodeURIComponent(encDisplayName);
expression = decodeURIComponent(encExpression);
+ description = encDescription
+ ? decodeURIComponent(encDescription)
+ : undefined;
} catch {
invalidEntries.push(entry);
continue;
@@ -73,6 +83,7 @@ export function fromEphemeralMeasuresParam(param: string): {
displayName,
expression,
...(formatPreset ? { formatPreset } : {}),
+ ...(description ? { description } : {}),
});
}
diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json
index e59646906b8..aec9d01707b 100644
--- a/web-common/src/lib/i18n/messages/en.json
+++ b/web-common/src/lib/i18n/messages/en.json
@@ -866,17 +866,22 @@
"dashboard_pivot_ephemeral_create": "Create adhoc measure",
"dashboard_pivot_ephemeral_display_name_label": "Name",
"dashboard_pivot_ephemeral_display_name_placeholder": "Profit",
+ "dashboard_pivot_ephemeral_description_label": "Description",
+ "dashboard_pivot_ephemeral_description_placeholder": "Shown in the measure's tooltip",
"dashboard_pivot_ephemeral_edit": "Edit",
"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_hint": "Combine existing measures with + - * / % and functions like round() or coalesce(). Type @ to pick a measure by name.",
"dashboard_pivot_ephemeral_expression_label": "Expression",
- "dashboard_pivot_ephemeral_expression_placeholder": "e.g. revenue - cost",
+ "dashboard_pivot_ephemeral_expression_placeholder": "e.g. revenue - cost (type @ to insert a measure)",
"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_mention_select": "Select",
+ "dashboard_pivot_ephemeral_mention_navigate": "Navigate,",
+ "dashboard_pivot_ephemeral_mention_label": "Measures",
"dashboard_pivot_ephemeral_new_title": "New adhoc measure",
"dashboard_pivot_ephemeral_save": "Save",
"dashboard_pivot_building_table": "Hang tight! We're building your table...",
diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json
index a802e9b2cba..57f009fbd9b 100644
--- a/web-common/src/lib/i18n/messages/es.json
+++ b/web-common/src/lib/i18n/messages/es.json
@@ -866,17 +866,22 @@
"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_description_label": "Descripción",
+ "dashboard_pivot_ephemeral_description_placeholder": "Se muestra en la descripción emergente de la medida",
"dashboard_pivot_ephemeral_edit": "Editar",
"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_hint": "Combina medidas existentes con + - * / % y funciones como round() o coalesce(). Escribe @ para elegir una medida por su nombre.",
"dashboard_pivot_ephemeral_expression_label": "Expresión",
- "dashboard_pivot_ephemeral_expression_placeholder": "p. ej. revenue - cost",
+ "dashboard_pivot_ephemeral_expression_placeholder": "p. ej. revenue - cost (escribe @ para insertar una medida)",
"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_mention_select": "Seleccionar",
+ "dashboard_pivot_ephemeral_mention_navigate": "Navegar,",
+ "dashboard_pivot_ephemeral_mention_label": "Medidas",
"dashboard_pivot_ephemeral_new_title": "Nueva medida ad hoc",
"dashboard_pivot_ephemeral_save": "Guardar",
"dashboard_pivot_building_table": "¡Un momento! Estamos construyendo tu tabla...",
diff --git a/web-local/tests/explores/pivot-ephemeral-measures.spec.ts b/web-local/tests/explores/pivot-ephemeral-measures.spec.ts
index 21ac2d34996..f0179f78db9 100644
--- a/web-local/tests/explores/pivot-ephemeral-measures.spec.ts
+++ b/web-local/tests/explores/pivot-ephemeral-measures.spec.ts
@@ -136,4 +136,73 @@ test.describe("pivot ephemeral measures from URL state", () => {
page.getByLabel("Bad pivot chip", { exact: true }),
).toHaveCount(0);
});
+
+ test("inserts a measure from the @ picker in the expression input", async ({
+ page,
+ }) => {
+ const currentUrl = new URL(page.url());
+ const baseUrl = `${currentUrl.protocol}//${currentUrl.host}`;
+
+ await waitForReconciliation(page);
+
+ await page.goto(
+ `${baseUrl}/explore/AdBids_metrics_explore?view=pivot&rows=publisher&cols=total_records`,
+ );
+
+ await page.getByRole("button", { name: "Create adhoc measure" }).click();
+ await page.getByLabel("Name", { exact: true }).fill("Doubled");
+
+ // Typing "@" opens a picker searchable by display name; the field name is
+ // shown alongside so users learn it.
+ const expression = page.getByLabel("Expression", { exact: true });
+ await expression.pressSequentially("@records");
+ const picker = page.getByRole("listbox", { name: "Measures" });
+ await expect(
+ picker.getByRole("option", { name: "Total records total_records" }),
+ ).toBeVisible();
+ await expect(
+ picker.getByRole("option", { name: "Sum of Bid Price bid_price_sum" }),
+ ).toHaveCount(0);
+ // The "@" is not a valid expression character, but no error shows while
+ // the picker is open.
+ await expect(page.getByText('unexpected character "@"')).toHaveCount(0);
+
+ // Enter replaces the "@query" with a chip showing the display name, which
+ // serializes to the measure's field name in the saved expression.
+ await expression.press("Enter");
+ await expect(picker).toHaveCount(0);
+ await expect(
+ expression.locator(".measure-chip", { hasText: "Total records" }),
+ ).toBeVisible();
+
+ // The "Insert a measure" buttons insert a chip too.
+ await expression.pressSequentially("* 2 +");
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Total records", exact: true })
+ .click();
+ await expect(expression.locator(".measure-chip")).toHaveCount(2);
+ await page.getByLabel("Description").fill("Twice");
+ await page.getByRole("button", { name: "Save" }).click();
+
+ await expect(
+ page.getByLabel("Doubled pivot chip", { exact: true }),
+ ).toBeVisible();
+ // The description rides along in the URL after the (empty) format preset.
+ const url = new URL(page.url());
+ expect(url.searchParams.get("adhoc_m")).toBe(
+ "doubled:Doubled:total_records%20*%202%20%2B%20total_records::Twice",
+ );
+
+ // A second measure with the same display name gets a numeric suffix.
+ await page.getByRole("button", { name: "Create adhoc measure" }).click();
+ await page.getByLabel("Name", { exact: true }).fill("doubled");
+ await page
+ .getByLabel("Expression", { exact: true })
+ .fill("total_records * 3");
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(
+ page.getByLabel("doubled_1 pivot chip", { exact: true }),
+ ).toBeVisible();
+ });
});