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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions proto/rill/ui/v1/dashboard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions runtime/canvas/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions runtime/canvas/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -995,7 +995,7 @@ leaderboard:
metrics_view: mv1
measures: [profit]
dimensions: [foo]
ephemeral_measures:
calculated_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -1067,7 +1067,7 @@ pie_chart:
color:
field: foo
type: nominal
ephemeral_measures:
calculated_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -1087,7 +1087,7 @@ heatmap:
color:
field: profit
type: quantitative
ephemeral_measures:
calculated_measures:
- name: profit
display_name: Profit
expression: y - z
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
40 changes: 28 additions & 12 deletions web-admin/src/features/public-urls/form-utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -77,10 +78,33 @@ 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 isSharedPivotChip = (chip: { id: string; type: PivotChipType }) =>
metricsViewFields.includes(chip.id) ||
sanitizedEphemeralNames.has(chip.id) ||
chip.type === PivotChipType.Time;
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
Expand Down Expand Up @@ -123,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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> = 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;
Expand Down Expand Up @@ -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)}
<span class="text-[10px] font-semibold italic">
ƒx
</span>
{/if}
</span>
{#if onEditEphemeral && ephemeralNames.has(item.id)}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => editEphemeral(e, item.id)}
onmousedown={(e) => e.stopPropagation()}
aria-label={m.dashboard_pivot_ephemeral_edit_title()}
type="button"
>
<PencilIcon size="14px" />
</button>
{/if}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => {
Expand Down Expand Up @@ -384,7 +413,23 @@
class="truncate min-w-0 flex-1 text-left pointer-events-none"
>
{displayName}
{#if ephemeralNames.has(item.id)}
<span class="text-[10px] font-semibold italic">
ƒx
</span>
{/if}
</span>
{#if onEditEphemeral && ephemeralNames.has(item.id)}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => editEphemeral(e, item.id)}
onmousedown={(e) => e.stopPropagation()}
aria-label={m.dashboard_pivot_ephemeral_edit_title()}
type="button"
>
<PencilIcon size="14px" />
</button>
{/if}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => {
Expand Down Expand Up @@ -475,7 +520,23 @@
class="truncate min-w-0 flex-1 text-left pointer-events-none"
>
{displayName}
{#if ephemeralNames.has(item.id)}
<span class="text-[10px] font-semibold italic">
ƒx
</span>
{/if}
</span>
{#if onEditEphemeral && ephemeralNames.has(item.id)}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => editEphemeral(e, item.id)}
onmousedown={(e) => e.stopPropagation()}
aria-label={m.dashboard_pivot_ephemeral_edit_title()}
type="button"
>
<PencilIcon size="14px" />
</button>
{/if}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => {
Expand Down Expand Up @@ -504,7 +565,23 @@
class="truncate min-w-0 flex-1 text-left pointer-events-none"
>
{displayName}
{#if ephemeralNames.has(item.id)}
<span class="text-[10px] font-semibold italic">
ƒx
</span>
{/if}
</span>
{#if onEditEphemeral && ephemeralNames.has(item.id)}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => editEphemeral(e, item.id)}
onmousedown={(e) => e.stopPropagation()}
aria-label={m.dashboard_pivot_ephemeral_edit_title()}
type="button"
>
<PencilIcon size="14px" />
</button>
{/if}
<button
class="{toggleButtonBaseClass} ml-auto"
onclick={(e) => {
Expand Down Expand Up @@ -546,6 +623,8 @@
: m.explore_clear_search_to_reorder_dimensions()}
</div>
{/if}

<slot name="action" close={() => (active = false)} />
</div>
</Popover.Content>
</Popover.Root>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
export let selectedItems: string[];
export let tooltipText: string;
export let label: string;
// Marks the chip's current selection as an ephemeral measure.
export let fx = false;
export let onSelect: (name: string) => void;
// When set, ephemeral items in the menu get an edit button that closes the
// menu and invokes this with the item name.
export let onEditItem: ((name: string) => void) | undefined = undefined;

let open = false;
let searchText = "";
Expand All @@ -35,8 +40,11 @@
suppress={open}
>
<Chip {...props} theme type="measure" active={open} {label}>
<div slot="body" class="font-bold truncate">
{label}
<div slot="body" class="flex items-center gap-x-1 font-bold">
<span class="truncate">{label}</span>
{#if fx}
<span class="flex-none text-[10px] font-semibold italic">ƒx</span>
{/if}
</div>
</Chip>
<div slot="tooltip-content" transition:fly={{ duration: 300, y: 4 }}>
Expand All @@ -54,5 +62,11 @@
{onSelect}
selectedItems={[selectedItems]}
selectableGroups={[{ name: "", items: selectableItems }]}
onEditItem={onEditItem
? (name) => {
open = false;
onEditItem?.(name);
}
: undefined}
/>
</DropdownMenu.Root>
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ export interface SearchableFilterSelectableGroup {
export interface SearchableFilterSelectableItem {
name: string;
label: string;
// Shown as a native tooltip on the menu row.
description?: string;
// Marks ephemeral measures with an fx icon.
ephemeral?: boolean;
}
Loading
Loading