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} +
+ +
+ {/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(); + }); });