- {#if toggleFilterPin || toggleFilterRequired}
+ {#if yamlConfigProvider.editable}
-
{label}
+
{dimensionManager.label}
- {#if toggleFilterRequired}
-
{
- curRequired = !curRequired;
- }}
- />
- {/if}
- {#if toggleFilterPin}
- {
- curPinned = !curPinned;
- }}
- />
- {/if}
+ (curRequired = !curRequired)}
+ />
+ (curPinned = !curPinned)}
+ />
{/if}
{#if showExtraInfo}
- {#if curMode !== DimensionFilterMode.Select}
+ {#if proxyDimensionManager.mode !== DimensionFilterMode.Select}
{searchResultCountText}
@@ -586,21 +488,23 @@
List is too long. Please remove some values.
- {:else if correctedSearchResults}
-
+ {:else}
+
- {#if curMode === DimensionFilterMode.Select && !curSearchText}
+ {#if proxyDimensionManager.mode === DimensionFilterMode.Select && !curSearchText}
{#each checkedItems as name (name)}
{@const selected = effectiveSelectedValues.includes(name)}
{@const label = name ?? "null"}
- handleItemClick(name)}
+ showXForSelected={proxyDimensionManager.exclude}
+ onCheckedChange={() => handleItemClick(name)}
>
{#if label.length > 240}
@@ -609,12 +513,12 @@
{label}
{/if}
-
+
{/each}
{/if}
- {#if curMode === DimensionFilterMode.Select && !curSearchText && checkedItems.length > 0 && uncheckedItems.length > 0}
+ {#if proxyDimensionManager.mode === DimensionFilterMode.Select && !curSearchText && checkedItems.length > 0 && uncheckedItems.length > 0}
{/if}
@@ -622,20 +526,23 @@
{#each uncheckedItems as name (name)}
{@const selected = effectiveSelectedValues.includes(name)}
{@const label = name ?? "null"}
-
- handleItemClick(name)}
+ checked={proxyDimensionManager.mode ===
+ DimensionFilterMode.Select && selected}
+ showXForSelected={proxyDimensionManager.exclude}
+ disabled={proxyDimensionManager.mode !==
+ DimensionFilterMode.Select}
+ onCheckedChange={() => handleItemClick(name)}
>
{#if label.length > 240}
@@ -644,11 +551,11 @@
{label}
{/if}
-
+
{/each}
- {#if uncheckedItems.length === 0 && (curMode !== DimensionFilterMode.Select || checkedItems.length === 0)}
+ {#if uncheckedItems.length === 0 && (proxyDimensionManager.mode !== DimensionFilterMode.Select || checkedItems.length === 0)}
no results
@@ -658,8 +565,8 @@
void;
+ export let onToggleExcludeMode: () => void;
export let onToggleSelectAll: () => void;
export let onApply: () => void;
diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts
new file mode 100644
index 000000000000..4ba51ae55520
--- /dev/null
+++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts
@@ -0,0 +1,222 @@
+import { page } from "$app/state";
+import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
+import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts";
+import {
+ type V1Expression,
+ V1Operation,
+} from "@rilldata/web-common/runtime-client";
+import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts";
+import {
+ createInExpression,
+ createLikeExpression,
+ getValuesInExpression,
+} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts";
+import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts";
+import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import { getDimensionDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts";
+
+export class DimensionFilterManager {
+ public expr: V1Expression | undefined = $state(undefined);
+ // String representation of the filter expression. Used to check duplicate expressions across metrics views.
+ public param: string = $state("");
+
+ public mode = $state(DimensionFilterMode.Select);
+ public selectedValues = $state([]);
+ public inputText = $state("");
+ public exclude = $state(false);
+
+ private oldMode: DimensionFilterMode;
+
+ public constructor(
+ public readonly name: string,
+ public readonly label: string,
+ initExpr: V1Expression = createInExpression(name, []),
+ isInList: boolean = false,
+ // Filter dropdown doesnt immediately apply changes.
+ // This marks this manager as ephemeral, it will not notify about mode changes.
+ private readonly ephemeral: boolean = false,
+ ) {
+ this.reconcile(initExpr, isInList ? [name] : []);
+ }
+
+ public static createForMetricsViews(
+ metricsViewsProvider: MetricsViewsProvider,
+ name: string,
+ mvName?: string,
+ initExpr?: V1Expression,
+ isInList?: boolean,
+ ) {
+ const dimensionSpecs = metricsViewsProvider.dimensionSpecs[name];
+ if (!dimensionSpecs) return undefined;
+ const dimensionSpec = mvName
+ ? dimensionSpecs[mvName]
+ : Object.values(dimensionSpecs)[0];
+ if (!dimensionSpec) return undefined;
+
+ return new DimensionFilterManager(
+ name,
+ getDimensionDisplayName(dimensionSpec),
+ initExpr,
+ isInList,
+ );
+ }
+
+ public reconcile(expr: V1Expression, inList: string[]) {
+ let initMode: DimensionFilterMode = DimensionFilterMode.Select;
+ let initSelectedValues: string[] = [];
+ let initInputText: string = "";
+ let initExclude: boolean = false;
+
+ const op = expr.cond?.op;
+ if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) {
+ initMode = inList.includes(this.name)
+ ? DimensionFilterMode.InList
+ : DimensionFilterMode.Select;
+ initSelectedValues = getValuesInExpression(expr);
+ initExclude = op === V1Operation.OPERATION_NIN;
+ } else if (
+ op === V1Operation.OPERATION_LIKE ||
+ op === V1Operation.OPERATION_NLIKE
+ ) {
+ initMode = DimensionFilterMode.Contains;
+ initInputText = sanitizeSearchText(
+ expr.cond?.exprs?.[1]?.val?.toString?.() ?? "",
+ );
+ initExclude = op === V1Operation.OPERATION_NLIKE;
+ }
+
+ this.mode = initMode;
+ this.oldMode = initMode;
+ this.selectedValues = initSelectedValues;
+ this.inputText = initInputText;
+ this.exclude = initExclude;
+ this.commit(false);
+ }
+
+ public clone() {
+ return new DimensionFilterManager(
+ this.name,
+ this.label,
+ this.expr,
+ this.mode === DimensionFilterMode.InList,
+ true,
+ );
+ }
+
+ public apply(dimensionManager: DimensionFilterManager) {
+ this.mode = dimensionManager.mode;
+ this.selectedValues = [...dimensionManager.selectedValues];
+ this.inputText = dimensionManager.inputText;
+ this.exclude = dimensionManager.exclude;
+ this.commit();
+ }
+
+ public setSelectedValues(dimensionValues: string[], exclude: boolean) {
+ this.mode = DimensionFilterMode.Select;
+ this.selectedValues = dimensionValues;
+ this.inputText = "";
+ this.exclude = exclude;
+ this.commit();
+ }
+
+ public toggleValue(dimensionValue: string, isExclusiveFilter: boolean) {
+ const inIdx = this.selectedValues.findIndex((v) => v === dimensionValue);
+
+ if (inIdx === -1) {
+ if (isExclusiveFilter) {
+ this.selectedValues = [dimensionValue];
+ } else {
+ this.selectedValues = [...this.selectedValues, dimensionValue];
+ }
+ } else {
+ this.selectedValues = this.selectedValues.toSpliced(inIdx, 1);
+ }
+ this.commit();
+ }
+
+ public appendSelectedValues(dimensionValues: string[]) {
+ const newValues = dimensionValues.filter(
+ (v) => !this.selectedValues.includes(v),
+ );
+ this.selectedValues = [...this.selectedValues, ...newValues];
+ this.commit();
+ return newValues;
+ }
+
+ public removeSelectedValues(dimensionValues: string[]) {
+ this.selectedValues = this.selectedValues.filter(
+ (v) => !dimensionValues.includes(v),
+ );
+ this.commit();
+ }
+
+ public setInList(values: string[], exclude: boolean) {
+ this.mode = DimensionFilterMode.InList;
+ this.selectedValues = values;
+ this.inputText = "";
+ this.exclude = exclude;
+ this.commit();
+ }
+
+ public setContainsText(searchText: string, exclude: boolean) {
+ this.mode = DimensionFilterMode.Contains;
+ this.selectedValues = [];
+ this.inputText = searchText;
+ this.exclude = exclude;
+ this.commit();
+ }
+
+ public toggleExclude() {
+ this.exclude = !this.exclude;
+ this.commit();
+ }
+
+ public clear() {
+ this.selectedValues = [];
+ this.inputText = "";
+ this.commit();
+ }
+
+ public commit(notify: boolean = true) {
+ switch (this.mode) {
+ case DimensionFilterMode.Select:
+ if (
+ this.oldMode !== DimensionFilterMode.Select &&
+ !this.ephemeral &&
+ notify
+ ) {
+ eventBus.emit("notification", {
+ message: m.filter_converted_to_select(),
+ link: {
+ text: m.common_undo(),
+ href: page.url.href,
+ },
+ });
+ }
+ // eslint-disable-next-line no-fallthrough
+ case DimensionFilterMode.InList:
+ this.expr = this.selectedValues.length
+ ? createInExpression(this.name, this.selectedValues, this.exclude)
+ : undefined;
+ break;
+
+ case DimensionFilterMode.Contains:
+ this.expr = this.inputText
+ ? createLikeExpression(this.name, `%${this.inputText}%`, this.exclude)
+ : undefined;
+ break;
+ }
+ this.oldMode = this.mode;
+
+ this.param = this.expr
+ ? convertExpressionToFilterParam(
+ this.expr,
+ this.mode === DimensionFilterMode.InList ? [this.name] : [],
+ )
+ : "";
+ }
+}
+
+export function sanitizeSearchText(searchText: string) {
+ return searchText.replace(/^%/, "").replace(/%$/, "");
+}
diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterReadOnlyChip.svelte b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterReadOnlyChip.svelte
deleted file mode 100644
index c606ad0b3099..000000000000
--- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterReadOnlyChip.svelte
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
diff --git a/web-common/src/features/dashboards/filters/dimension-filters/ReadonlyDimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters/ReadonlyDimensionFilter.svelte
new file mode 100644
index 000000000000..49dd9aee1d3c
--- /dev/null
+++ b/web-common/src/features/dashboards/filters/dimension-filters/ReadonlyDimensionFilter.svelte
@@ -0,0 +1,126 @@
+
+
+
+
+
diff --git a/web-common/src/features/dashboards/filters/dimension-filters/dimension-filter-values.ts b/web-common/src/features/dashboards/filters/dimension-filters/dimension-filter-values.ts
index dde8b1356f59..79fed870ea1e 100644
--- a/web-common/src/features/dashboards/filters/dimension-filters/dimension-filter-values.ts
+++ b/web-common/src/features/dashboards/filters/dimension-filters/dimension-filter-values.ts
@@ -6,7 +6,6 @@ import {
createAndExpression,
} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
-
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import {
createQueryServiceMetricsViewAggregation,
@@ -14,7 +13,6 @@ import {
} from "@rilldata/web-common/runtime-client";
import type { V1Expression } from "@rilldata/web-common/runtime-client";
import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
-import { mergeDimensionAndMeasureFilters } from "../measure-filters/measure-filter-utils";
import { getFiltersForOtherDimensions } from "../../selectors";
type DimensionSearchArgs = {
@@ -59,14 +57,10 @@ export function useDimensionSearch(
mode,
searchText,
values,
- // TODO - revist whether passing an empty array is the correct approach - bgh
additionalFilter: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- getFiltersForOtherDimensions(
- metricsViewWheres?.get(mvName) ?? createAndExpression([]),
- dimensionName,
- ),
- [],
+ getFiltersForOtherDimensions(
+ metricsViewWheres?.get(mvName) ?? createAndExpression([]),
+ dimensionName,
),
undefined,
),
@@ -149,12 +143,9 @@ export function useAllSearchResultsCount(
searchText,
values,
additionalFilter: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- getFiltersForOtherDimensions(
- metricsViewWheres?.get(mvName) ?? createAndExpression([]),
- dimensionName,
- ),
- [],
+ getFiltersForOtherDimensions(
+ metricsViewWheres?.get(mvName) ?? createAndExpression([]),
+ dimensionName,
),
undefined,
),
diff --git a/web-common/src/features/dashboards/filters/dimension-filters/queries.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters/queries.svelte.ts
new file mode 100644
index 000000000000..11184b0611e1
--- /dev/null
+++ b/web-common/src/features/dashboards/filters/dimension-filters/queries.svelte.ts
@@ -0,0 +1,217 @@
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts";
+import {
+ getQueryServiceMetricsViewAggregationQueryOptions,
+ V1BuiltinMeasure,
+ type V1Expression,
+} from "@rilldata/web-common/runtime-client";
+import {
+ createAndExpression,
+ createInExpression,
+ createLikeExpression,
+} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts";
+import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts";
+import { createReactiveQueries } from "@rilldata/web-common/lib/svelte-query/reactive-queries.svelte.ts";
+
+type DimensionSearchArgs = {
+ manager: ExpressionFilterManager;
+ dimensionName: string;
+ mode: DimensionFilterMode;
+ searchText: string;
+ values: string[];
+ timeStart?: string;
+ timeEnd?: string;
+ timeDimension?: string;
+ enabled?: boolean;
+};
+
+/**
+ * Returns the search results from the search input in a dimension filter.
+ *
+ * A dimension can be defined by more than one metrics view, so this queries each of them and merges
+ * the values. `getArgs` is read reactively, as are the specs, so the queries follow both the input
+ * in the dropdown and the metrics views as they load.
+ *
+ * Must be called during component init, once per dimension filter.
+ */
+export function createDimensionSearchQuery(
+ client: RuntimeClient,
+ getArgs: () => DimensionSearchArgs,
+) {
+ return createReactiveQueries(
+ () => {
+ const {
+ manager,
+ dimensionName,
+ mode,
+ searchText,
+ values,
+ timeStart,
+ timeEnd,
+ timeDimension,
+ enabled,
+ } = getArgs();
+
+ return getMetricsViewsForDimension(manager, dimensionName).map(
+ (metricsView) =>
+ getQueryServiceMetricsViewAggregationQueryOptions(
+ client,
+ {
+ metricsView,
+ dimensions: [{ name: dimensionName }],
+ timeRange: { start: timeStart, end: timeEnd, timeDimension },
+ limit: "250",
+ offset: "0",
+ sort: [{ name: dimensionName }],
+ where: getFilterForSearchArgs(dimensionName, {
+ mode,
+ searchText,
+ values,
+ additionalFilter: manager.getOtherDimensionsFilter(
+ dimensionName,
+ metricsView,
+ ),
+ }),
+ },
+ {
+ query: {
+ enabled,
+ select: (resp) =>
+ resp.data?.map((d) => d[dimensionName] as string) ?? [],
+ },
+ },
+ ),
+ );
+ },
+ (valuesPerMetricsView) => [
+ ...new Set(valuesPerMetricsView.flatMap((values) => values ?? [])),
+ ],
+ );
+}
+
+/**
+ * Returns the matched search results count.
+ *
+ * 1. For Select this will be disabled.
+ * 2. For InList mode, it returns the count of values actually present in the data source.
+ * 3. For Contains mode, it returns the count of values matching the search text.
+ *
+ * Must be called during component init, once per dimension filter.
+ */
+export function createDimensionSearchCountQuery(
+ client: RuntimeClient,
+ getArgs: () => DimensionSearchArgs,
+) {
+ return createReactiveQueries(
+ () => {
+ const {
+ manager,
+ dimensionName,
+ mode,
+ searchText,
+ values,
+ timeStart,
+ timeEnd,
+ timeDimension,
+ enabled,
+ } = getArgs();
+ const countMeasureName = dimensionName + "__distinct_count";
+
+ return getMetricsViewsForDimension(manager, dimensionName).map(
+ (metricsView) =>
+ getQueryServiceMetricsViewAggregationQueryOptions(
+ client,
+ {
+ metricsView,
+ measures: [
+ {
+ name: countMeasureName,
+ builtinMeasure:
+ V1BuiltinMeasure.BUILTIN_MEASURE_COUNT_DISTINCT,
+ builtinMeasureArgs: [dimensionName],
+ },
+ ],
+ timeRange: { start: timeStart, end: timeEnd, timeDimension },
+ where: getFilterForSearchArgs(dimensionName, {
+ mode,
+ searchText,
+ values,
+ additionalFilter: manager.getOtherDimensionsFilter(
+ dimensionName,
+ metricsView,
+ ),
+ }),
+ },
+ {
+ query: {
+ enabled,
+ select: (resp) =>
+ resp.data?.length
+ ? (resp.data[0][countMeasureName] as number)
+ : 0,
+ },
+ },
+ ),
+ );
+ },
+ // Absent while the queries are loading or disabled, so that the chip does not show a count yet.
+ (countPerMetricsView) =>
+ countPerMetricsView.some((count) => count !== undefined)
+ ? countPerMetricsView.reduce(
+ (total, count) => (total ?? 0) + (count ?? 0),
+ 0,
+ )
+ : undefined,
+ );
+}
+
+/**
+ * Metrics views that define the dimension. A dimension filter only applies to those, and querying a
+ * metrics view without the dimension errors.
+ */
+function getMetricsViewsForDimension(
+ manager: ExpressionFilterManager,
+ dimensionName: string,
+) {
+ return Object.keys(
+ manager.metricsViewsProvider.dimensionSpecs[dimensionName] ?? {},
+ );
+}
+
+/**
+ * Builds the filter for dimension search results or dimension search results count.
+ * Note the difference, this is for the search results from the search input.
+ *
+ * 1. For Select mode, while the final query is an `in` filter, the search results from the search input is a `like` filter.
+ * 2. For InList mode it is an `in` filter with all the selected values.
+ * 3. For Contains mode it is a `like` filter.
+ */
+function getFilterForSearchArgs(
+ dimensionName: string,
+ {
+ mode,
+ searchText,
+ values,
+ additionalFilter,
+ }: {
+ mode: DimensionFilterMode;
+ searchText: string;
+ values: string[];
+ additionalFilter?: V1Expression;
+ },
+) {
+ let filter: V1Expression;
+ if (mode === DimensionFilterMode.InList) {
+ filter = createInExpression(dimensionName, values);
+ } else {
+ const addNull = searchText.length !== 0 && "null".includes(searchText);
+ filter = addNull
+ ? createInExpression(dimensionName, [null])
+ : createLikeExpression(dimensionName, `%${searchText}%`);
+ }
+
+ if (additionalFilter) {
+ return createAndExpression([filter, additionalFilter]);
+ }
+ return filter;
+}
diff --git a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte
index abf95db34215..c1b803578817 100644
--- a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte
+++ b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte
@@ -1,61 +1,86 @@
{
- if (open && pinned !== curPinned) {
- toggleFilterPin?.(name, metricsViewNames);
- }
- if (open && required !== curRequired) {
- toggleFilterRequired?.(name, metricsViewNames);
+ onOpenChange={(open) => {
+ if (open) {
+ curPinned = pinned;
+ curRequired = required;
+ } else {
+ persistPinnedAndRequired();
}
}}
>
@@ -72,40 +97,45 @@
{...props}
type="measure"
active={open}
- {label}
- gray={!filter}
+ label={measureManager.label}
+ gray={!measureManager.expr}
error={!!missingRequired}
theme
- {onRemove}
- removable={!curPinned && !required}
- removeTooltipText={m.dashboard_remove_label({ label })}
+ onRemove={() => measureManager.clear()}
+ removable={removable && !pinned && !required}
+ removeTooltipText={m.dashboard_remove_label({
+ label: measureManager.label,
+ })}
>
{
- return d.name === dimensionName;
+ return d.name === measureManager.dimension;
})?.displayName ?? ""}
{filter}
- {label}
+ label={measureManager.label}
slot="body"
/>
- {name}
+ {measureManager.name}
{required
? m.dashboard_required_measure()
- : label || ""}
{#if missingRequired}
{m.dashboard_filter_required_set_value()}
{:else}
- {m.dashboard_click_to_edit_values()}
+
+
+ {m.dashboard_click_to_edit_values()}
+
{/if}
@@ -116,24 +146,16 @@
{#if open}
{
- if (pinned !== curPinned) {
- toggleFilterPin?.(name, metricsViewNames);
- }
- if (required !== curRequired) {
- toggleFilterRequired?.(name, metricsViewNames);
- }
- onApply(params);
- }}
+ onApply={({ dimension, filter }) => onApply(dimension, filter)}
bind:pinned={curPinned}
bind:required={curRequired}
- showPinControl={!!toggleFilterPin}
- showRequiredControl={!!toggleFilterRequired}
+ showPinControl={yamlConfigProvider.editable}
+ showRequiredControl={yamlConfigProvider.editable}
{side}
/>
{/if}
diff --git a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts
new file mode 100644
index 000000000000..375a9a57366e
--- /dev/null
+++ b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts
@@ -0,0 +1,105 @@
+import type { V1Expression } from "@rilldata/web-common/runtime-client";
+import {
+ mapExprToMeasureFilter,
+ mapMeasureFilterToExpr,
+ type MeasureFilterEntry,
+} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts";
+import {
+ MeasureFilterOperation,
+ MeasureFilterType,
+} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options.ts";
+import {
+ createSubQueryExpression,
+ removeWrapperAndOrExpression,
+} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts";
+import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts";
+import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import { getMeasureDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts";
+
+export class MeasureFilterManager {
+ public expr: V1Expression | undefined = $state(undefined);
+ // String representation of the filter expression. Used to check duplicate expressions across metrics views.
+ public param: string = $state("");
+
+ public dimension = $state("");
+ public operation = $state(MeasureFilterOperation.LessThan);
+ public type = $state(MeasureFilterType.Value);
+ public value1 = $state("");
+ public value2 = $state("");
+
+ public constructor(
+ public readonly name: string,
+ public readonly label: string,
+ initExpr: V1Expression | undefined = undefined,
+ ) {
+ this.reconcile(initExpr);
+ }
+
+ public static createForMetricsViews(
+ metricsViewsProvider: MetricsViewsProvider,
+ name: string,
+ mvName?: string,
+ initExpr?: V1Expression,
+ ) {
+ const measureSpecs = metricsViewsProvider.measureSpecs[name];
+ if (!measureSpecs) return undefined;
+ const measureSpec = mvName
+ ? measureSpecs[mvName]
+ : Object.values(measureSpecs)[0];
+ if (!measureSpec) return undefined;
+
+ return new MeasureFilterManager(
+ name,
+ getMeasureDisplayName(measureSpec),
+ initExpr,
+ );
+ }
+
+ public reconcile(expr: V1Expression | undefined) {
+ const dimension = expr?.subquery?.dimension;
+
+ const unwrappedHavingFilter = removeWrapperAndOrExpression(
+ expr?.subquery?.having,
+ );
+ const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter);
+
+ this.dimension = dimension ?? "";
+ this.operation =
+ mappedMeasureFilter?.operation ?? MeasureFilterOperation.LessThan;
+ this.type = mappedMeasureFilter?.type ?? MeasureFilterType.Value;
+ this.value1 = mappedMeasureFilter?.value1 ?? "";
+ this.value2 = mappedMeasureFilter?.value2 ?? "";
+ this.commit();
+ }
+
+ public setMeasureFilter(dimension: string, newFilter: MeasureFilterEntry) {
+ this.dimension = dimension;
+ this.operation = newFilter.operation;
+ this.type = newFilter.type;
+ this.value1 = newFilter.value1;
+ this.value2 = newFilter.value2;
+ this.commit();
+ }
+
+ public clear() {
+ this.value1 = "";
+ this.value2 = "";
+ this.dimension = "";
+ this.commit();
+ }
+
+ public commit() {
+ const measureFilterExpr = mapMeasureFilterToExpr({
+ measure: this.name,
+ operation: this.operation,
+ type: this.type,
+ value1: this.value1,
+ value2: this.value2,
+ });
+ const hasFilter = Boolean(this.dimension && measureFilterExpr);
+ this.expr = hasFilter
+ ? createSubQueryExpression(this.dimension, [this.name], measureFilterExpr)
+ : undefined;
+ this.param = this.expr ? convertExpressionToFilterParam(this.expr, []) : "";
+ }
+}
diff --git a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterReadOnlyChip.svelte b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterReadOnlyChip.svelte
deleted file mode 100644
index aaa893337bf1..000000000000
--- a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterReadOnlyChip.svelte
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
diff --git a/web-common/src/features/dashboards/filters/measure-filters/ReadonlyMeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters/ReadonlyMeasureFilter.svelte
new file mode 100644
index 000000000000..1c9c454bd3ac
--- /dev/null
+++ b/web-common/src/features/dashboards/filters/measure-filters/ReadonlyMeasureFilter.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
diff --git a/web-common/src/features/dashboards/filters/measure-filters/measure-filter-utils.ts b/web-common/src/features/dashboards/filters/measure-filters/measure-filter-utils.ts
deleted file mode 100644
index dca091b76238..000000000000
--- a/web-common/src/features/dashboards/filters/measure-filters/measure-filter-utils.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- mapExprToMeasureFilter,
- mapMeasureFilterToExpr,
-} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry";
-import {
- createAndExpression,
- createSubQueryExpression,
- filterExpressions,
- isExpressionUnsupported,
- removeWrapperAndOrExpression,
-} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
-import type { DimensionThresholdFilter } from "@rilldata/web-common/features/dashboards/stores/explore-state";
-import type { V1Expression } from "@rilldata/web-common/runtime-client";
-
-export function mergeDimensionAndMeasureFilters(
- whereFilter: V1Expression | undefined,
- dimensionThresholdFilters: DimensionThresholdFilter[],
-) {
- if (!whereFilter) return createAndExpression([]);
- const where =
- filterExpressions(whereFilter, () => true) ?? createAndExpression([]);
- where.cond?.exprs?.push(
- ...dimensionThresholdFilters.map(convertDimensionThresholdFilter),
- );
- return where;
-}
-
-/**
- * Splits where filter into dimension and measure filters.
- * Measure filters will be sub-queries
- */
-export function splitWhereFilter(whereFilter: V1Expression | undefined) {
- if (whereFilter && isExpressionUnsupported(whereFilter)) {
- return { dimensionFilters: whereFilter, dimensionThresholdFilters: [] };
- }
-
- const dimensionFilters = createAndExpression([]);
- const dimensionThresholdFilters: DimensionThresholdFilter[] = [];
- whereFilter?.cond?.exprs?.filter((e) => {
- const subqueryExpr = e.cond?.exprs?.[1];
-
- // While all the types support multiple measure filters per dimension our UI doesn't allow this right now.
- // So unwrap while trying to validate a measure filter.
- const unwrappedHavingFilter = removeWrapperAndOrExpression(
- subqueryExpr?.subquery?.having,
- );
- const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter);
- // If there is no valid measure filter at level one then we do not support it right now.
- if (!mappedMeasureFilter) {
- dimensionFilters.cond?.exprs?.push(e);
- } else {
- dimensionThresholdFilters.push({
- name: subqueryExpr?.subquery?.dimension ?? "",
- filters: [mappedMeasureFilter],
- });
- }
- });
-
- return { dimensionFilters, dimensionThresholdFilters };
-}
-
-function convertDimensionThresholdFilter(
- dtf: DimensionThresholdFilter,
-): V1Expression {
- return createSubQueryExpression(
- dtf.name,
- dtf.filters.map((f) => f.measure),
- createAndExpression(
- dtf.filters.map(mapMeasureFilterToExpr).filter(Boolean) as V1Expression[],
- ),
- );
-}
diff --git a/web-common/src/features/dashboards/filters/test/render-filter-component.ts b/web-common/src/features/dashboards/filters/test/render-filter-component.ts
deleted file mode 100644
index afec7c2c3fc2..000000000000
--- a/web-common/src/features/dashboards/filters/test/render-filter-component.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import Filters from "@rilldata/web-common/features/dashboards/filters/Filters.svelte";
-import { DEFAULT_STORE_KEY } from "@rilldata/web-common/features/dashboards/state-managers/state-managers";
-import { AD_BIDS_METRICS_NAME } from "@rilldata/web-common/features/dashboards/stores/test-data/data";
-import { initStateManagers } from "@rilldata/web-common/features/dashboards/stores/test-data/helpers";
-import {
- RUNTIME_CONTEXT_KEY,
- RuntimeClient,
-} from "@rilldata/web-common/runtime-client/v2";
-import { render } from "@testing-library/svelte";
-
-export function renderFilterComponent(hasTimeSeries = false) {
- const { stateManagers, queryClient } = initStateManagers();
-
- const renderResults = render(Filters, {
- props: {
- timeRanges: [],
- metricsViewName: AD_BIDS_METRICS_NAME,
- hasTimeSeries,
- },
- context: new Map([
- [DEFAULT_STORE_KEY as unknown as string, stateManagers as unknown],
- ["$$_queryClient", queryClient as unknown],
- [
- RUNTIME_CONTEXT_KEY,
- new RuntimeClient({ host: "http://localhost", instanceId: "test" }),
- ],
- ]),
- });
-
- return { stateManagers, queryClient, renderResults };
-}
diff --git a/web-common/src/features/dashboards/filters/utils.ts b/web-common/src/features/dashboards/filters/utils.ts
new file mode 100644
index 000000000000..6adb5a779026
--- /dev/null
+++ b/web-common/src/features/dashboards/filters/utils.ts
@@ -0,0 +1,13 @@
+import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts";
+import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+
+export function getMissingRequiredFilters(
+ expressionFilterManager: ExpressionFilterManager,
+ yamlConfigProvider: YAMLConfigProvider,
+) {
+ return Object.keys(yamlConfigProvider.requiredFilters).filter(
+ (filterName) => {
+ return !expressionFilterManager.filterManagersMap[filterName]?.expr;
+ },
+ );
+}
diff --git a/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte b/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte
index 5c72ec89b690..67b69b2364a8 100644
--- a/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte
+++ b/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte
@@ -16,12 +16,10 @@
} from "@rilldata/web-common/runtime-client";
import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2";
import { onMount } from "svelte";
- import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state";
import {
getComparisonRequestMeasures,
getURIRequestMeasure,
} from "../dashboard-utils";
- import { mergeDimensionAndMeasureFilters } from "../filters/measure-filters/measure-filter-utils";
import { SortType } from "../proto-state/derived-types";
import { getFiltersForOtherDimensions } from "../selectors";
import { getMeasuresForDimensionOrLeaderboardDisplay } from "../state-managers/selectors/dashboard-queries";
@@ -51,8 +49,7 @@
export let timeRange: V1TimeRange;
export let comparisonTimeRange: V1TimeRange | undefined;
export let selectedValues: ReturnType;
- export let whereFilter: V1Expression;
- export let dimensionThresholdFilters: DimensionThresholdFilter[];
+ export let whereFilter: V1Expression | undefined;
export let leaderboardSortByMeasureName: string;
export let leaderboardMeasures: MetricsViewSpecMeasure[];
export let leaderboardShowContextForAllMeasures: boolean;
@@ -141,20 +138,14 @@
$: isComplexFilter = isExpressionUnsupported(whereFilter);
$: where = isComplexFilter
? whereFilter
- : sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- getFiltersForOtherDimensions(whereFilter, dimensionName),
- dimensionThresholdFilters,
- ),
- undefined,
- );
+ : getFiltersForOtherDimensions(whereFilter, dimensionName);
$: measures = [
...getMeasuresForDimensionOrLeaderboardDisplay(
leaderboardShowContextForAllMeasures
? null
: leaderboardSortByMeasureName,
- dimensionThresholdFilters,
+ whereFilter,
leaderboardMeasureNames,
).map((name) => ({ name }) as V1MetricsViewAggregationMeasure),
diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte
index d8b0af4a7f86..65e34bbfaa95 100644
--- a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte
+++ b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte
@@ -7,7 +7,6 @@
V1TimeRange,
} from "@rilldata/web-common/runtime-client";
import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2";
- import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state";
import { clamp } from "@rilldata/web-common/lib/clamp";
import Leaderboard from "./Leaderboard.svelte";
import LeaderboardControls from "./LeaderboardControls.svelte";
@@ -20,8 +19,7 @@
} from "./leaderboard-widths";
export let metricsViewName: string;
- export let whereFilter: V1Expression;
- export let dimensionThresholdFilters: DimensionThresholdFilter[];
+ export let whereFilter: V1Expression | undefined;
export let timeRange: V1TimeRange;
export let comparisonTimeRange: V1TimeRange | undefined;
export let timeControlsReady: boolean;
@@ -35,7 +33,6 @@
measureTooltipFormatters,
activeMeasureTooltipFormatter,
},
- dimensionFilters: { isFilterExcludeMode },
dimensions: { visibleDimensions },
comparison: { isBeingCompared: isBeingComparedReadable },
sorting: { sortedAscending, sortType },
@@ -49,11 +46,10 @@
actions: {
dimensions: { setPrimaryDimension },
sorting: { toggleSort },
- dimensionsFilter: { toggleDimensionValueSelection },
comparison: { toggleComparisonDimension },
},
exploreName,
- dashboardStore,
+ expressionFilterManager,
} = StateManagers;
const client = useRuntimeClient();
@@ -105,13 +101,14 @@
leaderboardMeasures={$leaderboardMeasures}
leaderboardShowContextForAllMeasures={$leaderboardShowContextForAllMeasures}
{whereFilter}
- {dimensionThresholdFilters}
{tableWidth}
{timeRange}
{dimensionColumnWidth}
sortedAscending={$sortedAscending}
sortType={$sortType}
- filterExcludeMode={$isFilterExcludeMode(dimension.name)}
+ filterExcludeMode={expressionFilterManager.filterManagers.dimensions.find(
+ (dfm) => dfm.name === dimension.name,
+ )?.exclude ?? false}
{comparisonTimeRange}
{dimension}
{parentElement}
@@ -119,7 +116,7 @@
selectedValues={selectedDimensionValues(
client,
[metricsViewName],
- $dashboardStore.whereFilter,
+ whereFilter,
dimension.name,
timeRange.start,
timeRange.end,
@@ -136,7 +133,12 @@
}}
{setPrimaryDimension}
{toggleSort}
- {toggleDimensionValueSelection}
+ toggleDimensionValueSelection={(_1, value, _2, exclusive) =>
+ expressionFilterManager.dimensionFilterAction(
+ dimension.name!,
+ (dimensionManager) =>
+ dimensionManager.toggleValue(value, exclusive ?? false),
+ )}
{toggleComparisonDimension}
measureLabel={$measureLabel}
onDimensionColumnResize={dimensionColumn.set}
diff --git a/web-common/src/features/dashboards/pivot/pivot-data-config.ts b/web-common/src/features/dashboards/pivot/pivot-data-config.ts
index 3c932647090c..183f6ee3ea18 100644
--- a/web-common/src/features/dashboards/pivot/pivot-data-config.ts
+++ b/web-common/src/features/dashboards/pivot/pivot-data-config.ts
@@ -1,4 +1,3 @@
-import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
import { allDimensions } from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimensions";
import { allMeasures } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures";
import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers";
@@ -147,10 +146,7 @@ export function getPivotConfig(
validMetricsView: metricsView,
validExplore: explore,
}),
- whereFilter: mergeDimensionAndMeasureFilters(
- dashboardStore.whereFilter,
- dashboardStore.dimensionThresholdFilters,
- ),
+ whereFilter: dashboardStore.whereFilter,
pivot: dashboardStore.pivot,
enableComparison,
comparisonTime,
diff --git a/web-common/src/features/dashboards/pivot/pivot-export.ts b/web-common/src/features/dashboards/pivot/pivot-export.ts
index c2f81b82822f..0591d4f39c79 100644
--- a/web-common/src/features/dashboards/pivot/pivot-export.ts
+++ b/web-common/src/features/dashboards/pivot/pivot-export.ts
@@ -1,5 +1,4 @@
import { getDimensionForTimeField } from "@rilldata/web-common/features/dashboards/aggregation-request/dimension-utils.ts";
-import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state";
import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store";
@@ -194,13 +193,7 @@ export function getPivotAggregationRequest({
? prepareMeasureForComparison(measures)
: measures,
dimensions: allDimensions,
- where: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- exploreState.whereFilter,
- exploreState.dimensionThresholdFilters,
- ),
- undefined,
- ),
+ where: sanitiseExpression(exploreState.whereFilter, undefined),
pivotOn,
sort,
offset: "0",
diff --git a/web-common/src/features/dashboards/proto-state/fromProto.ts b/web-common/src/features/dashboards/proto-state/fromProto.ts
index d3045c4ebd0b..88267d948131 100644
--- a/web-common/src/features/dashboards/proto-state/fromProto.ts
+++ b/web-common/src/features/dashboards/proto-state/fromProto.ts
@@ -1,8 +1,4 @@
import { protoBase64, type Timestamp } from "@bufbuild/protobuf";
-import {
- mapExprToMeasureFilter,
- type MeasureFilterEntry,
-} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry";
import { LeaderboardContextColumn } from "@rilldata/web-common/features/dashboards/leaderboard-context-column";
import {
type PivotChipData,
@@ -20,7 +16,9 @@ import {
import { convertFilterToExpression } from "@rilldata/web-common/features/dashboards/proto-state/filter-converter";
import {
createAndExpression,
+ createSubQueryExpression,
filterIdentifiers,
+ getAllIdentifiers,
} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state";
import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types";
@@ -115,15 +113,18 @@ export function getDashboardStateFromProto(
if (dashboard.dimensionsWithInlistFilter) {
entity.dimensionsWithInlistFilter = dashboard.dimensionsWithInlistFilter;
}
- if (dashboard.having) {
- entity.dimensionThresholdFilters = dashboard.having.map((h) => {
- const expr = fromExpressionProto(h.filter as Expression);
- return {
- name: h.name,
- filters: expr?.cond?.exprs
- ?.map(mapExprToMeasureFilter)
- .filter(Boolean) as MeasureFilterEntry[],
- };
+ // Explore state keeps measure filters collapsed into the where filter as subqueries.
+ // Older protos stored them separately in `having`, so merge those back in.
+ entity.dimensionThresholdFilters = [];
+ if (dashboard.having.length) {
+ entity.whereFilter ??= createAndExpression([]);
+ const exprs = entity.whereFilter.cond?.exprs;
+ dashboard.having.forEach((h) => {
+ if (!h.filter) return;
+ const expr = fromExpressionProto(h.filter);
+ exprs?.push(
+ createSubQueryExpression(h.name, getAllIdentifiers(expr), expr),
+ );
});
}
if (dashboard.compareTimeRange) {
@@ -267,6 +268,20 @@ export function fromExpressionProto(
.filter((e): e is V1Expression => e !== undefined),
},
};
+
+ case "subquery":
+ return {
+ subquery: {
+ dimension: expression.expression.value.dimension,
+ measures: expression.expression.value.measures,
+ where:
+ expression.expression.value.where &&
+ fromExpressionProto(expression.expression.value.where),
+ having:
+ expression.expression.value.having &&
+ fromExpressionProto(expression.expression.value.having),
+ },
+ };
}
}
diff --git a/web-common/src/features/dashboards/proto-state/sparse-proto.spec.ts b/web-common/src/features/dashboards/proto-state/sparse-proto.spec.ts
index 1d145cf27f14..5bb455e83cd6 100644
--- a/web-common/src/features/dashboards/proto-state/sparse-proto.spec.ts
+++ b/web-common/src/features/dashboards/proto-state/sparse-proto.spec.ts
@@ -6,6 +6,8 @@ import {
AD_BIDS_EXPLORE_INIT,
AD_BIDS_EXPLORE_NAME,
AD_BIDS_METRICS_INIT,
+ AD_BIDS_METRICS_VIEW,
+ AD_BIDS_NAME,
AD_BIDS_TIME_RANGE_SUMMARY,
} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
import {
@@ -34,6 +36,7 @@ import {
import { deepClone } from "@vitest/utils/helpers";
import { get } from "svelte/store";
import { beforeEach, describe, expect, it } from "vitest";
+import { useTestFilterManager } from "@rilldata/web-common/features/dashboards/url-state/test/url-state-test-utils.ts";
const TestCases: {
title: string;
@@ -81,6 +84,12 @@ const TestCasesOppositeMutations = [
AD_BIDS_OPEN_DOM_BP_PIVOT,
];
+// Filters live in the ExpressionFilterManager rather than in explore state, so the tests need the
+// specs of the metrics view backing AD_BIDS_EXPLORE to build the filter chips.
+const getFilterManager = useTestFilterManager({
+ [AD_BIDS_NAME]: AD_BIDS_METRICS_VIEW,
+});
+
describe("sparse proto", () => {
beforeEach(() => {
resetDashboardStore();
@@ -102,7 +111,11 @@ describe("sparse proto", () => {
AD_BIDS_EXPLORE_INIT,
);
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ mutations,
+ getFilterManager(),
+ );
metricsExplorerStore.syncFromUrl(
AD_BIDS_EXPLORE_NAME,
@@ -118,7 +131,11 @@ describe("sparse proto", () => {
describe("should reset partial dashboard store", () => {
for (const { title, mutations, keys } of TestCases) {
it(`to ${title}`, async () => {
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ mutations,
+ getFilterManager(),
+ );
const partialDashboard = getPartialDashboard(
AD_BIDS_EXPLORE_NAME,
keys,
@@ -131,6 +148,7 @@ describe("sparse proto", () => {
await applyMutationsToDashboard(
AD_BIDS_EXPLORE_NAME,
TestCasesOppositeMutations,
+ getFilterManager(),
);
metricsExplorerStore.syncFromUrl(
diff --git a/web-common/src/features/dashboards/proto-state/toProto.ts b/web-common/src/features/dashboards/proto-state/toProto.ts
index 3faa4c2fd515..89e2e1da40bf 100644
--- a/web-common/src/features/dashboards/proto-state/toProto.ts
+++ b/web-common/src/features/dashboards/proto-state/toProto.ts
@@ -5,7 +5,6 @@ import {
Timestamp,
Value,
} from "@bufbuild/protobuf";
-import { mapMeasureFilterToExpr } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry";
import { LeaderboardContextColumn } from "@rilldata/web-common/features/dashboards/leaderboard-context-column";
import { splitPivotChips } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils";
import {
@@ -19,7 +18,6 @@ import {
ToProtoTimeGrainMap,
} from "@rilldata/web-common/features/dashboards/proto-state/enum-maps";
import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state";
-import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types";
import { arrayOrderedEquals } from "@rilldata/web-common/lib/arrayUtils";
import type {
@@ -33,9 +31,9 @@ import {
import {
Condition,
Expression,
+ Subquery,
} from "@rilldata/web-common/proto/gen/rill/runtime/v1/expression_pb";
import {
- DashboardDimensionFilter,
DashboardState,
DashboardState_ActivePage,
DashboardState_LeaderboardContextColumn,
@@ -84,21 +82,6 @@ export function getProtoFromDashboardState(
if (exploreState.dimensionsWithInlistFilter) {
state.dimensionsWithInlistFilter = exploreState.dimensionsWithInlistFilter;
}
- if (exploreState.dimensionThresholdFilters?.length) {
- state.having = exploreState.dimensionThresholdFilters.map(
- ({ name, filters }) =>
- new DashboardDimensionFilter({
- name,
- filter: toExpressionProto(
- createAndExpression(
- filters
- .map(mapMeasureFilterToExpr)
- .filter(Boolean) as V1Expression[],
- ),
- ),
- }),
- );
- }
if (exploreState.selectedTimeRange) {
state.timeRange = toTimeRangeProto(exploreState.selectedTimeRange);
if (exploreState.selectedTimeRange.interval) {
@@ -251,6 +234,24 @@ function toExpressionProto(expression: V1Expression): Expression {
},
});
}
+ // Measure filters are stored in the where filter as subqueries.
+ if (expression.subquery) {
+ return new Expression({
+ expression: {
+ case: "subquery",
+ value: new Subquery({
+ dimension: expression.subquery.dimension,
+ measures: expression.subquery.measures,
+ where:
+ expression.subquery.where &&
+ toExpressionProto(expression.subquery.where),
+ having:
+ expression.subquery.having &&
+ toExpressionProto(expression.subquery.having),
+ }),
+ },
+ });
+ }
return new Expression();
}
diff --git a/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts
new file mode 100644
index 000000000000..fe5c62c393e5
--- /dev/null
+++ b/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts
@@ -0,0 +1,72 @@
+import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import {
+ createQueryServiceResolveCanvas,
+ createRuntimeServiceGetExplore,
+} from "@rilldata/web-common/runtime-client";
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+
+/**
+ * Metrics view name and other yaml config provider based on dashboard type.
+ */
+export class DashboardConfigProvider {
+ public readonly metricsViewsProvider: MetricsViewsProvider;
+ public readonly yamlConfigProvider: YAMLConfigProvider;
+
+ // TODO: ensure cleanup is called
+ public cleanup: (() => void) | undefined = undefined;
+
+ public constructor(runtimeClient: RuntimeClient) {
+ this.metricsViewsProvider = new MetricsViewsProvider(runtimeClient, []);
+ this.yamlConfigProvider = new YAMLConfigProvider();
+ }
+}
+
+export class ExploreDashboardConfigProvider extends DashboardConfigProvider {
+ public constructor(runtimeClient: RuntimeClient, exploreName: string) {
+ super(runtimeClient);
+
+ const getExploreQuery = createRuntimeServiceGetExplore(runtimeClient, {
+ name: exploreName,
+ });
+ this.cleanup = getExploreQuery.subscribe((getExploreResp) => {
+ const exploreSpec =
+ getExploreResp.data?.explore?.explore?.state?.validSpec ?? {};
+
+ this.metricsViewsProvider.setMetricsViewNames(
+ exploreSpec.metricsView ? [exploreSpec.metricsView] : [],
+ );
+
+ // this.yamlConfigProvider.update() // TODO: once we have this support for explore
+ });
+ }
+}
+
+export class CanvasDashboardConfigProvider extends DashboardConfigProvider {
+ public constructor(runtimeClient: RuntimeClient, canvasName: string) {
+ super(runtimeClient);
+
+ const resolveCanvasQuery = createQueryServiceResolveCanvas(runtimeClient, {
+ canvas: canvasName,
+ });
+ this.cleanup = resolveCanvasQuery.subscribe((resolveCanvasResp) => {
+ const canvasSpec =
+ resolveCanvasResp.data?.canvas?.canvas?.state?.validSpec ?? {};
+
+ this.metricsViewsProvider.setMetricsViewNames(
+ Object.keys(resolveCanvasResp.data?.referencedMetricsViews ?? {}),
+ );
+
+ const defaultFilters = Object.fromEntries(
+ Object.entries(canvasSpec.defaultPreset?.filterExpr ?? {}).map(
+ ([mv, sqlFilter]) => [mv, sqlFilter.expression],
+ ),
+ );
+ this.yamlConfigProvider.update(
+ defaultFilters,
+ canvasSpec.pinnedFilters ?? [],
+ canvasSpec.requiredFilters ?? [],
+ );
+ });
+ }
+}
diff --git a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts
new file mode 100644
index 000000000000..b1f5e187f522
--- /dev/null
+++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts
@@ -0,0 +1,55 @@
+import { type V1Expression } from "@rilldata/web-common/runtime-client";
+
+/**
+ * A provider for YAML only configuration. These are only mutable during yaml editing.
+ */
+export class YAMLConfigProvider {
+ public defaultFilters = $state>({});
+ public pinnedFilters = $state>({});
+ public specPinnedFilters = $state>({});
+ public requiredFilters = $state>({});
+ public specRequiredFilters = $state>({});
+ public editable: boolean = false;
+
+ public cleanup: (() => void) | undefined = undefined;
+
+ public update(
+ defaultFilters: Record,
+ pinnedFilters: string[],
+ requiredFilters: string[],
+ ) {
+ this.defaultFilters = defaultFilters;
+
+ const pinnedFiltersRec = Object.fromEntries(
+ pinnedFilters.map((filter) => [filter, true]),
+ );
+ this.pinnedFilters = { ...pinnedFiltersRec };
+ this.specPinnedFilters = { ...pinnedFiltersRec };
+
+ const requiredFiltersRec = Object.fromEntries(
+ requiredFilters.map((filter) => [filter, true]),
+ );
+ this.requiredFilters = { ...requiredFiltersRec };
+ this.specRequiredFilters = { ...requiredFiltersRec };
+ }
+
+ public setEditable(newEditable: boolean) {
+ this.editable = newEditable;
+ }
+
+ public togglePinnedFilter(filter: string) {
+ if (!this.pinnedFilters[filter]) {
+ this.pinnedFilters[filter] = true;
+ } else {
+ delete this.pinnedFilters[filter];
+ }
+ }
+
+ public toggleRequiredFilter(filter: string) {
+ if (!this.requiredFilters[filter]) {
+ this.requiredFilters[filter] = true;
+ } else {
+ delete this.requiredFilters[filter];
+ }
+ }
+}
diff --git a/web-common/src/features/dashboards/rows-viewer/RowsViewerAccordion.svelte b/web-common/src/features/dashboards/rows-viewer/RowsViewerAccordion.svelte
index 8a33eac5c93f..8ba21c9612da 100644
--- a/web-common/src/features/dashboards/rows-viewer/RowsViewerAccordion.svelte
+++ b/web-common/src/features/dashboards/rows-viewer/RowsViewerAccordion.svelte
@@ -12,7 +12,6 @@
import { useExploreState } from "web-common/src/features/dashboards/stores/dashboard-stores";
import ExportMenu from "../../exports/ExportMenu.svelte";
import { featureFlags } from "../../feature-flags";
- import { mergeDimensionAndMeasureFilters } from "../filters/measure-filters/measure-filter-utils";
import type { PivotFilter } from "../pivot/types";
import RowsViewer from "./RowsViewer.svelte";
@@ -45,7 +44,7 @@
const client = useRuntimeClient();
$: exploreState = useExploreState(exploreName);
- $: ({ whereFilter, dimensionThresholdFilters } = $exploreState);
+ $: whereFilter = $exploreState.whereFilter;
$: pivotDataStore = usePivotForExplore(stateManagers);
$: ({ activeCellFilters } = $pivotDataStore);
$: showPivot = $showPivotStore;
@@ -65,10 +64,7 @@
$: filters = isPivotCellSelected
? sanitiseExpression((activeCellFilters as PivotFilter).filters, undefined)
- : sanitiseExpression(
- mergeDimensionAndMeasureFilters(whereFilter, dimensionThresholdFilters),
- undefined,
- );
+ : sanitiseExpression(whereFilter, undefined);
$: filteredTotalsQuery = createQueryServiceMetricsViewAggregation(
client,
@@ -136,13 +132,7 @@
timeStart: timeRange.start,
timeEnd: timeRange.end,
timeDimension: $exploreState?.selectedTimeDimension,
- where: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- $exploreState.whereFilter,
- $exploreState.dimensionThresholdFilters,
- ),
- undefined,
- ),
+ where: sanitiseExpression($exploreState.whereFilter, undefined),
},
};
}
diff --git a/web-common/src/features/dashboards/selectors.ts b/web-common/src/features/dashboards/selectors.ts
index 1efb46af765c..fc12af9dcd59 100644
--- a/web-common/src/features/dashboards/selectors.ts
+++ b/web-common/src/features/dashboards/selectors.ts
@@ -1,5 +1,7 @@
import {
createAndExpression,
+ forEachExpression,
+ isSubqueryExpression,
matchExpressionByName,
} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import {
@@ -32,7 +34,6 @@ import {
type QueryClient,
} from "@tanstack/svelte-query";
import { derived, type Readable } from "svelte/store";
-import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state";
export function useMetricsView(
client: RuntimeClient,
@@ -205,33 +206,36 @@ export function hasValidMetricsViewTimeRange(
}
export function getFiltersForOtherDimensions(
- whereFilter: V1Expression,
+ whereFilter: V1Expression | undefined,
dimName: string,
) {
- const exprIdx = whereFilter?.cond?.exprs?.findIndex((e) =>
- matchExpressionByName(e, dimName),
+ if (!whereFilter) return undefined;
+
+ const exprIdx = whereFilter?.cond?.exprs?.findIndex(
+ (e) => matchExpressionByName(e, dimName) && !isSubqueryExpression(e),
);
if (exprIdx === undefined || exprIdx === -1) return whereFilter;
return createAndExpression(
whereFilter.cond?.exprs?.filter(
- (e) => !matchExpressionByName(e, dimName),
+ (e) => !matchExpressionByName(e, dimName) || isSubqueryExpression(e),
) ?? [],
);
}
export function additionalMeasures(
activeMeasureName: string | null,
- dimensionThresholdFilters: DimensionThresholdFilter[],
+ expr: V1Expression | undefined,
) {
const measures = new Set(
activeMeasureName ? [activeMeasureName] : [],
);
- dimensionThresholdFilters.forEach(({ filters }) => {
- filters.forEach((filter) => {
- measures.add(filter.measure);
+ if (expr) {
+ forEachExpression(expr, (e) => {
+ if (!e.subquery?.measures) return;
+ e.subquery.measures.forEach((m) => measures.add(m));
});
- });
+ }
return [...measures];
}
diff --git a/web-common/src/features/dashboards/state-managers/StateManagersProvider.svelte b/web-common/src/features/dashboards/state-managers/StateManagersProvider.svelte
index d446c88b0a45..3857bbaf2f0b 100644
--- a/web-common/src/features/dashboards/state-managers/StateManagersProvider.svelte
+++ b/web-common/src/features/dashboards/state-managers/StateManagersProvider.svelte
@@ -1,5 +1,5 @@
diff --git a/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts b/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts
index 5252f0eadfdd..2d627f1dbf1f 100644
--- a/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts
+++ b/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts
@@ -1,324 +1 @@
-import { page } from "$app/stores";
-import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
-import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
-import {
- createInExpression,
- createLikeExpression,
- getValuesInExpression,
- negateExpression,
-} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
-import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus";
-import {
- type V1Expression,
- V1Operation,
-} from "@rilldata/web-common/runtime-client";
-import { get } from "svelte/store";
-import { getWhereFilterExpressionIndex } from "../selectors/dimension-filters";
-import type { DashboardMutables } from "./types";
-
-export function toggleDimensionValueSelection(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- dimensionValue: string | null,
- keepPillVisible?: boolean,
- /**
- * This marks the value as being exclusive. All other selected values will be unselected.
- */
- isExclusiveFilter?: boolean,
-) {
- return toggleMultipleDimensionValueSelections(
- { dashboard },
- dimensionName,
- [dimensionValue],
- keepPillVisible,
- isExclusiveFilter,
- );
-}
-
-export function toggleMultipleDimensionValueSelections(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- dimensionValues: (string | null)[],
- keepPillVisible?: boolean,
- isExclusiveFilter?: boolean,
- exclude?: boolean,
-) {
- if (dashboard.temporaryFilterName !== null) {
- dashboard.temporaryFilterName = null;
- }
-
- if (exclude !== undefined) {
- dashboard.dimensionFilterExcludeMode.set(dimensionName, exclude);
- }
- const isExclude =
- exclude ?? !!dashboard.dimensionFilterExcludeMode.get(dimensionName);
- const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName);
- if (exprIdx === undefined || exprIdx === -1) {
- dashboard.whereFilter.cond?.exprs?.push(
- createInExpression(dimensionName, dimensionValues, isExclude),
- );
- return;
- }
-
- const expr = dashboard.whereFilter.cond?.exprs?.[exprIdx];
- if (!expr?.cond?.exprs) {
- // should never happen since getWhereFilterExpressionIndex runs a find
- return;
- }
-
- const wasInListFilter =
- dashboard.dimensionsWithInlistFilter.includes(dimensionName);
- const wasLikeFilter =
- expr.cond?.op === V1Operation.OPERATION_LIKE ||
- expr.cond?.op === V1Operation.OPERATION_NLIKE;
- if (wasInListFilter || wasLikeFilter) {
- eventBus.emit("notification", {
- message: m.filter_converted_to_select(),
- link: {
- text: m.common_undo(),
- href: get(page).url.href,
- },
- });
- }
-
- dashboard.dimensionsWithInlistFilter =
- dashboard.dimensionsWithInlistFilter.filter((d) => d !== dimensionName);
- if (wasLikeFilter) {
- eventBus.emit("notification", {
- message: m.filter_converted_to_select(),
- link: {
- text: m.common_undo(),
- href: get(page).url.href,
- },
- });
- dashboard.whereFilter.cond!.exprs![exprIdx] = createInExpression(
- dimensionName,
- dimensionValues,
- isExclude,
- );
- return;
- }
-
- dimensionValues.forEach((v) => {
- const removedIndex = toggleDimensionFilterValue(
- expr,
- v,
- !!isExclusiveFilter,
- );
- if (removedIndex === -1) return;
-
- // Only decrement pinIndex if the removed value was before the pinned value
- if (dashboard.tdd.pinIndex >= removedIndex) {
- dashboard.tdd.pinIndex--;
- }
- });
-
- // remove the dimension entry if all values are removed
- if (expr.cond.exprs.length === 1) {
- dashboard.whereFilter.cond?.exprs?.splice(exprIdx, 1);
- if (keepPillVisible) {
- dashboard.temporaryFilterName = dimensionName;
- }
- }
-}
-
-export function applyDimensionInListMode(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- values: string[],
-) {
- if (dashboard.temporaryFilterName !== null) {
- dashboard.temporaryFilterName = null;
- }
-
- if (!dashboard.whereFilter.cond?.exprs) return;
-
- const isExclude = !!dashboard.dimensionFilterExcludeMode.get(dimensionName);
- const expr = createInExpression(dimensionName, values, isExclude);
- if (!dashboard.dimensionsWithInlistFilter.includes(dimensionName)) {
- dashboard.dimensionsWithInlistFilter.push(dimensionName);
- }
- const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName);
- if (exprIdx === undefined || exprIdx === -1) {
- dashboard.whereFilter.cond.exprs.push(expr);
- } else {
- dashboard.whereFilter.cond.exprs[exprIdx] = expr;
- }
-}
-
-export function applyDimensionContainsMode(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- searchText: string,
-) {
- if (dashboard.temporaryFilterName !== null) {
- dashboard.temporaryFilterName = null;
- }
-
- if (!dashboard.whereFilter.cond?.exprs) return;
-
- const isExclude = !!dashboard.dimensionFilterExcludeMode.get(dimensionName);
- const expr = createLikeExpression(
- dimensionName,
- `%${searchText}%`,
- isExclude,
- );
- const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName);
- if (exprIdx === undefined || exprIdx === -1) {
- dashboard.whereFilter.cond.exprs.push(expr);
- } else {
- dashboard.whereFilter.cond.exprs[exprIdx] = expr;
- }
-}
-
-export function toggleDimensionFilterMode(
- { dashboard }: DashboardMutables,
- dimensionName: string,
-) {
- const exclude = dashboard.dimensionFilterExcludeMode.get(dimensionName);
- dashboard.dimensionFilterExcludeMode.set(dimensionName, !exclude);
-
- if (!dashboard.whereFilter?.cond?.exprs) {
- return;
- }
-
- const exprIdx = dashboard.whereFilter.cond.exprs.findIndex(
- (e) => e.cond?.exprs?.[0].ident === dimensionName,
- );
- if (exprIdx === -1) {
- return;
- }
- dashboard.whereFilter.cond.exprs[exprIdx] = negateExpression(
- dashboard.whereFilter.cond.exprs[exprIdx],
- );
-}
-
-export function removeDimensionFilter(
- { dashboard }: DashboardMutables,
- dimensionName: string,
-) {
- if (dashboard.temporaryFilterName === dimensionName) {
- dashboard.temporaryFilterName = null;
- return;
- }
-
- const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName);
- if (exprIdx === undefined || exprIdx === -1) return;
- dashboard.whereFilter?.cond?.exprs?.splice(exprIdx, 1);
-}
-
-export function selectItemsInFilter(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- values: (string | null)[],
-) {
- const isExclude = !!dashboard.dimensionFilterExcludeMode.get(dimensionName);
- const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName);
- if (exprIdx === undefined || exprIdx === -1) {
- dashboard.whereFilter.cond?.exprs?.push(
- createInExpression(dimensionName, values, isExclude),
- );
- return;
- }
-
- const expr = dashboard.whereFilter.cond?.exprs?.[exprIdx];
- if (!expr?.cond?.exprs) {
- // should never happen since getWhereFilterExpressionIndex runs a find
- return;
- }
-
- // preserve old selections and add only new ones
- const oldValues = getValuesInExpression(expr);
- const newValues = values.filter((v) => !oldValues.includes(v));
- // newValuesSelected = newValues.length; // TODO
- expr.cond.exprs.push(...newValues.map((v): V1Expression => ({ val: v })));
-}
-
-export function deselectItemsInFilter(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- values: (string | null)[],
-) {
- const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName);
- if (exprIdx === undefined || exprIdx === -1) {
- return;
- }
-
- const expr = dashboard.whereFilter.cond?.exprs?.[exprIdx];
- if (!expr?.cond?.exprs) {
- // should never happen since getWhereFilterExpressionIndex runs a find
- return;
- }
-
- // remove only deselected values
- const oldValues = getValuesInExpression(expr);
- const newValues = oldValues.filter((v) => !values.includes(v));
-
- if (newValues.length) {
- expr.cond.exprs.splice(
- 1,
- expr.cond.exprs.length - 1,
- ...newValues.map((v): V1Expression => ({ val: v })),
- );
- } else {
- dashboard.whereFilter.cond?.exprs?.splice(exprIdx, 1);
- }
-}
-
-export function setFilters(
- { dashboard }: DashboardMutables,
- filter: V1Expression,
-) {
- const { dimensionFilters, dimensionThresholdFilters } =
- splitWhereFilter(filter);
- dashboard.whereFilter = dimensionFilters;
- dashboard.dimensionThresholdFilters = dimensionThresholdFilters;
-}
-
-export function toggleDimensionFilterValue(
- expr: V1Expression,
- dimensionValue: string | null,
- isExclusiveFilter: boolean,
-) {
- if (!expr.cond?.exprs) return -1;
-
- const ident = expr.cond.exprs[0];
- const values = getValuesInExpression(expr);
-
- const inIdx = values.findIndex((v) => v === dimensionValue);
-
- if (inIdx === -1) {
- if (isExclusiveFilter) {
- expr.cond.exprs = [ident, { val: dimensionValue }];
- return -1;
- } else {
- values.push(dimensionValue);
- }
- } else {
- values.splice(inIdx, 1);
- }
-
- expr.cond.exprs = [ident, ...values.map((v) => ({ val: v }))];
-
- return inIdx;
-}
-
-export const dimensionFilterActions = {
- /**
- * Toggles whether the given dimension value is selected in the
- * dimension filter for the given dimension.
- *
- * Note that this is different than the include/exclude mode for
- * dimension filters. This is a toggle for a specific value, whereas
- * the include/exclude mode is a toggle for the entire dimension.
- */
- toggleDimensionValueSelection,
- toggleMultipleDimensionValueSelections,
- applyDimensionInListMode,
- applyDimensionContainsMode,
- toggleDimensionFilterMode,
- removeDimensionFilter,
- selectItemsInFilter,
- deselectItemsInFilter,
- setFilters,
-};
+export const dimensionFilterActions = {};
diff --git a/web-common/src/features/dashboards/state-managers/actions/filters.ts b/web-common/src/features/dashboards/state-managers/actions/filters.ts
index 32749c48449f..a9306e9676df 100644
--- a/web-common/src/features/dashboards/state-managers/actions/filters.ts
+++ b/web-common/src/features/dashboards/state-managers/actions/filters.ts
@@ -1,33 +1 @@
-import type { DashboardMutables } from "@rilldata/web-common/features/dashboards/state-managers/actions/types";
-import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
-
-export function clearAllFilters({ dashboard }: DashboardMutables) {
- const hasFilters =
- dashboard.whereFilter.cond?.exprs?.length ||
- dashboard.dimensionThresholdFilters?.length;
- if (!hasFilters) {
- return;
- }
-
- dashboard.whereFilter = createAndExpression([]);
- dashboard.dimensionThresholdFilters = [];
- dashboard.temporaryFilterName = null;
- dashboard.dimensionFilterExcludeMode.clear();
- dashboard.tdd.pinIndex = -1;
-}
-
-export function setTemporaryFilterName(
- { dashboard }: DashboardMutables,
- name: string,
-) {
- dashboard.temporaryFilterName = name;
-}
-
-export const filterActions = {
- /**
- * Clears all filters and resets related fields
- */
- clearAllFilters,
-
- setTemporaryFilterName,
-};
+export const filterActions = {};
diff --git a/web-common/src/features/dashboards/state-managers/actions/measure-filters.ts b/web-common/src/features/dashboards/state-managers/actions/measure-filters.ts
index 8888e49c0c75..472a63ea62bc 100644
--- a/web-common/src/features/dashboards/state-managers/actions/measure-filters.ts
+++ b/web-common/src/features/dashboards/state-managers/actions/measure-filters.ts
@@ -1,72 +1 @@
-import type { MeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry";
-import type { DashboardMutables } from "@rilldata/web-common/features/dashboards/state-managers/actions/types";
-import type { DimensionThresholdFilter } from "@rilldata/web-common/features/dashboards/stores/explore-state";
-
-export function setMeasureFilter(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- filter: MeasureFilterEntry,
-) {
- if (dashboard.temporaryFilterName !== null) {
- dashboard.temporaryFilterName = null;
- }
-
- const dimId = dashboard.dimensionThresholdFilters.findIndex(
- (dtf) => dtf.name === dimensionName,
- );
- let dimThresholdFilter: DimensionThresholdFilter;
- if (dimId === -1) {
- dimThresholdFilter = {
- name: dimensionName,
- filters: [],
- };
- dashboard.dimensionThresholdFilters.push(dimThresholdFilter);
- } else {
- dimThresholdFilter = dashboard.dimensionThresholdFilters[dimId];
- }
-
- const exprIdx = dimThresholdFilter.filters.findIndex(
- (f) => f.measure === filter.measure,
- );
- if (exprIdx === -1) {
- // if there is no expression for the measure push to the end
- dimThresholdFilter.filters.push(filter);
- } else if (exprIdx >= 0) {
- // else replace the existing measure filter
- dimThresholdFilter.filters.splice(exprIdx, 1, filter);
- }
-}
-
-export function removeMeasureFilter(
- { dashboard }: DashboardMutables,
- dimensionName: string,
- measureName: string,
-) {
- if (dashboard.temporaryFilterName === measureName) {
- dashboard.temporaryFilterName = null;
- return;
- }
-
- const dimId = dashboard.dimensionThresholdFilters.findIndex(
- (dtf) => dtf.name === dimensionName,
- );
- if (dimId === -1) return;
- const dimThresholdFilter = dashboard.dimensionThresholdFilters[dimId];
-
- const exprIdx = dimThresholdFilter.filters.findIndex(
- (f) => f.measure === measureName,
- );
- if (exprIdx === -1) return;
- dimThresholdFilter.filters.splice(exprIdx, 1);
-
- // if dimension threshold filter is empty remove it
- if (dimThresholdFilter.filters.length === 0) {
- dashboard.dimensionThresholdFilters.splice(dimId, 1);
- }
-}
-
-export const measureFilterActions = {
- setMeasureFilter,
-
- removeMeasureFilter,
-};
+export const measureFilterActions = {};
diff --git a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.spec.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.spec.ts
index 435684076dae..e591d356fbda 100644
--- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.spec.ts
+++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.spec.ts
@@ -25,7 +25,7 @@ import {
AD_BIDS_PUBLISHER_DIMENSION,
} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
import { ExploreUrlWebView } from "@rilldata/web-common/features/dashboards/url-state/mappers";
-import { getCleanMetricsExploreForAssertion } from "@rilldata/web-common/features/dashboards/url-state/url-state-variations.spec";
+import { getCleanMetricsExploreForAssertion } from "@rilldata/web-common/features/dashboards/url-state/test/url-state-test-utils";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import { mockAnimationsForComponentTesting } from "@rilldata/web-common/lib/test/mock-animations";
import {
diff --git a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte
index 14c17a68a992..e8cc303f4e28 100644
--- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte
+++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte
@@ -22,6 +22,7 @@
import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2";
import { onDestroy } from "svelte";
import { clearExploreSessionStore } from "@rilldata/web-common/features/dashboards/state-managers/loaders/explore-web-view-store.ts";
+ import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers.ts";
export let exploreName: string;
export let storageNamespacePrefix: string | undefined = undefined;
@@ -33,6 +34,9 @@
const client = useRuntimeClient();
+ const StateManagers = getStateManagers();
+ const { expressionFilterManager } = StateManagers;
+
$: exploreSpecQuery = useExploreValidSpec(client, exploreName);
$: exploreSpec = $exploreSpecQuery.data?.explore ?? {};
$: metricsViewName = exploreSpec?.metricsView ?? "";
@@ -56,6 +60,7 @@
exploreName,
storageNamespacePrefix,
dataLoader,
+ expressionFilterManager,
);
}
diff --git a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts
index 7b1c3e3e1240..102130029eea 100644
--- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts
+++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts
@@ -20,6 +20,7 @@ import type { AfterNavigate } from "@sveltejs/kit";
import { getContext, setContext } from "svelte";
import { derived, get, type Readable } from "svelte/store";
import type { CompoundQueryResult } from "@rilldata/web-common/features/compound-query-result";
+import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts";
export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync");
@@ -54,6 +55,7 @@ export class DashboardStateSync {
private readonly exploreName: string,
private readonly extraPrefix: string | undefined,
private readonly dataLoader: DashboardStateDataLoader,
+ private readonly expressionFilterManager: ExpressionFilterManager,
) {
this.exploreStore = useExploreState(exploreName);
this.timeControlStore = createTimeControlStoreFromName(
@@ -185,6 +187,8 @@ export class DashboardStateSync {
);
}
+ log("INIT", redirectUrl);
+ this.expressionFilterManager.setUrlParams(redirectUrl.searchParams);
// If the current url same as the new url then there is no need to do anything
if (redirectUrl.search === pageState.url.search) {
this.initialized = true;
@@ -262,6 +266,7 @@ export class DashboardStateSync {
metricsExplorerStore.mergePartialExplorerEntity(
this.exploreName,
partialExplore,
+ this.expressionFilterManager,
);
// Get time controls state after explore state is updated.
const timeControlsState = get(this.timeControlStore);
@@ -294,6 +299,8 @@ export class DashboardStateSync {
this.updating = false;
}
+ log("URL", redirectUrl);
+ this.expressionFilterManager.setUrlParams(redirectUrl.searchParams);
// If the url doesn't need to be changed further then we can skip the goto
if (redirectUrl.search === pageState.url.search) {
return;
@@ -349,6 +356,8 @@ export class DashboardStateSync {
);
}
+ log("GOTO", newUrl);
+ this.expressionFilterManager.setUrlParams(newUrl.searchParams);
// If the state didnt result in a new url then skip goto.
// This avoids adding redundant urls to the history.
if (newUrl.search === pageState.url.search) {
@@ -362,3 +371,12 @@ export class DashboardStateSync {
}
}
}
+
+function log(label: string, toUrl: URL) {
+ const fromUrlSearch = get(page).url.search;
+ const toUrlSearch = toUrl.search;
+ const equal = fromUrlSearch === toUrlSearch;
+ console.log(
+ `[${label}] ${fromUrlSearch} =${equal ? "X" : "="}> ${toUrlSearch}`,
+ );
+}
diff --git a/web-common/src/features/dashboards/state-managers/loaders/explore-web-view-store.spec.ts b/web-common/src/features/dashboards/state-managers/loaders/explore-web-view-store.spec.ts
index e45ce5bd4f3f..38415c443610 100644
--- a/web-common/src/features/dashboards/state-managers/loaders/explore-web-view-store.spec.ts
+++ b/web-common/src/features/dashboards/state-managers/loaders/explore-web-view-store.spec.ts
@@ -11,6 +11,8 @@ import {
AD_BIDS_IMPRESSIONS_MEASURE,
AD_BIDS_METRICS_3_MEASURES_DIMENSIONS,
AD_BIDS_METRICS_NAME,
+ AD_BIDS_METRICS_VIEW,
+ AD_BIDS_NAME,
AD_BIDS_TIME_RANGE_SUMMARY,
} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
import {
@@ -28,7 +30,10 @@ import {
applyMutationsToDashboard,
type TestDashboardMutation,
} from "@rilldata/web-common/features/dashboards/stores/test-data/store-mutations";
-import { getCleanMetricsExploreForAssertion } from "@rilldata/web-common/features/dashboards/url-state/url-state-variations.spec";
+import {
+ getCleanMetricsExploreForAssertion,
+ useTestFilterManager,
+} from "@rilldata/web-common/features/dashboards/url-state/test/url-state-test-utils";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import {
RUNTIME_CONTEXT_KEY,
@@ -153,6 +158,12 @@ const TestCases: {
},
];
+// Filters live in the ExpressionFilterManager rather than in explore state, so the tests need the
+// specs of the metrics view backing AD_BIDS_EXPLORE to build the filter chips.
+const getFilterManager = useTestFilterManager({
+ [AD_BIDS_NAME]: AD_BIDS_METRICS_VIEW,
+});
+
describe("Explore web view store", () => {
const mocks = DashboardFetchMocks.useDashboardFetchMocks();
let pageMock!: PageMockForExploreTests;
@@ -186,21 +197,29 @@ describe("Explore web view store", () => {
await waitFor(() => expect(screen.getByText("Dashboard loaded!")));
// apply mutations to main view to setup the initial state
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, [
- AD_BIDS_APPLY_PUB_DIMENSION_FILTER,
- AD_BIDS_SET_P7D_TIME_RANGE_FILTER,
- AD_BIDS_SET_PREVIOUS_PERIOD_COMPARE_TIME_RANGE_FILTER,
- AD_BIDS_TOGGLE_BID_PRICE_MEASURE_VISIBILITY(AD_BIDS_EXPLORE_INIT),
- AD_BIDS_TOGGLE_BID_DOMAIN_DIMENSION_VISIBILITY(AD_BIDS_EXPLORE_INIT),
- AD_BIDS_SORT_ASC_BY_IMPRESSIONS,
- AD_BIDS_SORT_BY_PERCENT_VALUE,
- ]);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ [
+ AD_BIDS_APPLY_PUB_DIMENSION_FILTER,
+ AD_BIDS_SET_P7D_TIME_RANGE_FILTER,
+ AD_BIDS_SET_PREVIOUS_PERIOD_COMPARE_TIME_RANGE_FILTER,
+ AD_BIDS_TOGGLE_BID_PRICE_MEASURE_VISIBILITY(AD_BIDS_EXPLORE_INIT),
+ AD_BIDS_TOGGLE_BID_DOMAIN_DIMENSION_VISIBILITY(AD_BIDS_EXPLORE_INIT),
+ AD_BIDS_SORT_ASC_BY_IMPRESSIONS,
+ AD_BIDS_SORT_BY_PERCENT_VALUE,
+ ],
+ getFilterManager(),
+ );
const initialSearch = `view=${initView.view}${initView.additionalParams ?? ""}`;
// simulate going to the init view's url
pageMock.gotoSearch(initialSearch);
// apply any mutations in the init view
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, initView.mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ initView.mutations,
+ getFilterManager(),
+ );
const initState = getCleanMetricsExploreForAssertion();
@@ -208,7 +227,11 @@ describe("Explore web view store", () => {
// simulate going to the view's url
pageMock.gotoSearch(viewSearch);
// apply any mutations in the view
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, view.mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ view.mutations,
+ getFilterManager(),
+ );
const stateInView = getCleanMetricsExploreForAssertion();
// All history changes before this are a combination of visiting the view and mutations.
@@ -259,9 +282,11 @@ describe("Explore web view store", () => {
pageMock.assertSearchParams("");
pageMock.gotoSearch(tddSearch);
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, [
- AD_BIDS_SWITCH_TO_STACKED_BAR_IN_TDD,
- ]);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ [AD_BIDS_SWITCH_TO_STACKED_BAR_IN_TDD],
+ getFilterManager(),
+ );
pageMock.assertSearchParams(
`view=tdd&measure=${AD_BIDS_IMPRESSIONS_MEASURE}&chart_type=stacked_bar`,
);
diff --git a/web-common/src/features/dashboards/state-managers/loaders/test/DashboardStateManagerTest.svelte b/web-common/src/features/dashboards/state-managers/loaders/test/DashboardStateManagerTest.svelte
index ddf52fedd4ec..19beef13221a 100644
--- a/web-common/src/features/dashboards/state-managers/loaders/test/DashboardStateManagerTest.svelte
+++ b/web-common/src/features/dashboards/state-managers/loaders/test/DashboardStateManagerTest.svelte
@@ -1,10 +1,14 @@
-
- Dashboard loaded!
-
+
+
+ Dashboard loaded!
+
+
diff --git a/web-common/src/features/dashboards/state-managers/most-recent-explore-state.spec.ts b/web-common/src/features/dashboards/state-managers/most-recent-explore-state.spec.ts
index 725074f34920..a428f96f4b19 100644
--- a/web-common/src/features/dashboards/state-managers/most-recent-explore-state.spec.ts
+++ b/web-common/src/features/dashboards/state-managers/most-recent-explore-state.spec.ts
@@ -12,6 +12,7 @@ import {
AD_BIDS_EXPLORE_NAME,
AD_BIDS_METRICS_3_MEASURES_DIMENSIONS,
AD_BIDS_METRICS_NAME,
+ AD_BIDS_NAME,
AD_BIDS_PUBLISHER_DIMENSION,
AD_BIDS_TIME_RANGE_SUMMARY,
} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
@@ -25,7 +26,10 @@ import {
applyMutationsToDashboard,
type TestDashboardMutation,
} from "@rilldata/web-common/features/dashboards/stores/test-data/store-mutations";
-import { getCleanMetricsExploreForAssertion } from "@rilldata/web-common/features/dashboards/url-state/url-state-variations.spec";
+import {
+ getCleanMetricsExploreForAssertion,
+ useTestFilterManager,
+} from "@rilldata/web-common/features/dashboards/url-state/test/url-state-test-utils";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import {
DashboardState_LeaderboardSortDirection,
@@ -109,6 +113,12 @@ const TestCases: {
},
];
+// Filters live in the ExpressionFilterManager rather than in explore state, so the tests need the
+// specs of the metrics view backing AD_BIDS_EXPLORE to build the filter chips.
+const getFilterManager = useTestFilterManager({
+ [AD_BIDS_NAME]: AD_BIDS_METRICS_3_MEASURES_DIMENSIONS,
+});
+
describe("Most recent explore state", () => {
const mocks = DashboardFetchMocks.useDashboardFetchMocks();
let pageMock!: PageMockForExploreTests;
@@ -151,7 +161,11 @@ describe("Most recent explore state", () => {
const initState = getCleanMetricsExploreForAssertion();
pageMock.gotoSearch(urlSearch);
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ mutations,
+ getFilterManager(),
+ );
// clear the old dashboard to simulate closing the tab
unmount();
diff --git a/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts b/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts
index 407988549b7a..bb11ee660050 100644
--- a/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts
+++ b/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts
@@ -1,16 +1,14 @@
import { additionalMeasures } from "../../selectors";
-import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state";
+import type { V1Expression } from "@rilldata/web-common/runtime-client";
export function getMeasuresForDimensionOrLeaderboardDisplay(
sortByMeasureName: string | null,
- dimensionThresholdFilters: DimensionThresholdFilter[],
+ expr: V1Expression | undefined,
visibleMeasureNames: string[],
) {
const allMeasures = new Set([
...visibleMeasureNames,
- ...(sortByMeasureName
- ? additionalMeasures(sortByMeasureName, dimensionThresholdFilters)
- : []),
+ ...(sortByMeasureName ? additionalMeasures(sortByMeasureName, expr) : []),
]);
return [...allMeasures];
}
diff --git a/web-common/src/features/dashboards/state-managers/selectors/dimension-filters.ts b/web-common/src/features/dashboards/state-managers/selectors/dimension-filters.ts
index 94fd870222cd..5cdd34c5eb32 100644
--- a/web-common/src/features/dashboards/state-managers/selectors/dimension-filters.ts
+++ b/web-common/src/features/dashboards/state-managers/selectors/dimension-filters.ts
@@ -1,12 +1,12 @@
import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants";
import { useDimensionSearch } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/dimension-filter-values";
import { getDimensionDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName";
-import { filterItemsSortFunction } from "@rilldata/web-common/features/dashboards/state-managers/selectors/filters";
import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers";
import {
forEachIdentifier,
getValuesInExpression,
isExpressionUnsupported,
+ isSubqueryExpression,
matchExpressionByName,
} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import type {
@@ -38,8 +38,8 @@ export const selectedDimensionValues = (
data: [],
});
- const dimExpr = whereFilter.cond?.exprs?.find((e) =>
- matchExpressionByName(e, dimensionName),
+ const dimExpr = whereFilter.cond?.exprs?.find(
+ (e) => matchExpressionByName(e, dimensionName) && !isSubqueryExpression(e),
);
if (!dimExpr?.cond?.op)
return readable({
@@ -97,36 +97,12 @@ export const useSelectedValuesForCompareDimension = (ctx: StateManagers) => {
) as ReturnType;
};
-export const isFilterExcludeMode = (
- dashData: AtLeast,
-): ((dimName: string) => boolean) => {
- return (dimName: string) =>
- dashData.dashboard.dimensionFilterExcludeMode.get(dimName) ?? false;
-};
-
-export const dimensionHasFilter = (
- dashData: AtLeast,
-) => {
- return (dimName: string) => {
- return getWhereFilterExpression(dashData)(dimName) !== undefined;
- };
-};
-
-export const getWhereFilterExpression = (
- dashData: AtLeast,
-): ((name: string) => V1Expression | undefined) => {
- return (name: string) =>
- dashData.dashboard.whereFilter.cond?.exprs?.find((e) =>
- matchExpressionByName(e, name),
- );
-};
-
export const getWhereFilterExpressionIndex = (
dashData: AtLeast,
): ((name: string) => number | undefined) => {
return (name: string) =>
- dashData.dashboard.whereFilter?.cond?.exprs?.findIndex((e) =>
- matchExpressionByName(e, name),
+ dashData.dashboard.whereFilter?.cond?.exprs?.findIndex(
+ (e) => matchExpressionByName(e, name) && !isSubqueryExpression(e),
);
};
@@ -144,19 +120,6 @@ export type DimensionFilterItem = {
missingRequired?: boolean;
};
-export function getDimensionFilterItems(
- dashData: AtLeast,
-) {
- return (dimensionIdMap: Map) => {
- return getDimensionFilters(
- dimensionIdMap,
- dashData.dashboard.whereFilter,
- dashData.dashboard.dimensionsWithInlistFilter,
- dashData.validExplore?.metricsView,
- );
- };
-}
-
export function getDimensionFiltersMap(
dimensionIdMap: Map,
filter: V1Expression | undefined,
@@ -230,102 +193,4 @@ export function getDimensionFilters(
);
}
-export const getAllDimensionFilterItems = (
- dashData: AtLeast,
-) => {
- return (
- dimensionFilterItem: DimensionFilterItem[],
- dimensionIdMap: Map,
- ) => {
- const allDimensionFilterItem = [...dimensionFilterItem];
-
- // if the temporary filter is a dimension filter add it
- if (
- dashData.dashboard.temporaryFilterName &&
- dimensionIdMap.has(dashData.dashboard.temporaryFilterName) &&
- dashData.validExplore?.metricsView
- ) {
- allDimensionFilterItem.push({
- name: dashData.dashboard.temporaryFilterName,
- label: getDimensionDisplayName(
- dimensionIdMap.get(dashData.dashboard.temporaryFilterName),
- ),
- mode: DimensionFilterMode.Select,
- selectedValues: [],
- isInclude: true,
- dimensions: new Map([
- [
- dashData.validExplore?.metricsView,
- dimensionIdMap.get(dashData.dashboard.temporaryFilterName)!,
- ],
- ]),
- pinned: false,
- });
- }
-
- // sort based on name to make sure toggling include/exclude is not jarring
- return allDimensionFilterItem.sort(filterItemsSortFunction);
- };
-};
-
-export const unselectedDimensionValues = (
- dashData: AtLeast,
-) => {
- return (dimensionName: string, values: unknown[]): unknown[] => {
- const expr = getWhereFilterExpression(dashData)(dimensionName);
- if (expr === undefined) {
- return values;
- }
-
- return values.filter(
- (v) => expr.cond?.exprs?.findIndex((e) => e.val === v) === -1,
- );
- };
-};
-
-export const includedDimensionValues = (
- dashData: AtLeast,
-) => {
- return (dimensionName: string): unknown[] => {
- const expr = getWhereFilterExpression(dashData)(dimensionName);
- if (expr === undefined || expr.cond?.op !== V1Operation.OPERATION_IN) {
- return [];
- }
-
- return getValuesInExpression(expr);
- };
-};
-
-export const hasAtLeastOneDimensionFilter = (
- dashData: AtLeast,
-) => {
- const whereFilter = dashData.dashboard.whereFilter;
- return whereFilter.cond?.exprs?.length && whereFilter.cond.exprs.length > 0;
-};
-
-export const dimensionFilterSelectors = {
- /**
- * Returns a function that can be used to get whether the specified
- * dimension is in exclude mode.
- */
- isFilterExcludeMode,
-
- /**
- * Check if a dimension has any filter
- */
- dimensionHasFilter,
-
- /**
- * Get filter items based on currently selected values for a dimension
- */
- getDimensionFilterItems,
-
- /**
- * Get filter items on dimension along with an empty entry for temporary filter if it is a dimension
- */
- getAllDimensionFilterItems,
-
- unselectedDimensionValues,
- includedDimensionValues,
- hasAtLeastOneDimensionFilter,
-};
+export const dimensionFilterSelectors = {};
diff --git a/web-common/src/features/dashboards/state-managers/selectors/measure-filters.ts b/web-common/src/features/dashboards/state-managers/selectors/measure-filters.ts
index b56f65e63c7a..ab4ae392c3bd 100644
--- a/web-common/src/features/dashboards/state-managers/selectors/measure-filters.ts
+++ b/web-common/src/features/dashboards/state-managers/selectors/measure-filters.ts
@@ -1,127 +1 @@
-import { getMeasureDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName";
-import type { MeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry";
-import type { DashboardDataSources } from "@rilldata/web-common/features/dashboards/state-managers/selectors/types";
-import type { AtLeast } from "@rilldata/web-common/features/dashboards/state-managers/types";
-import type { DimensionThresholdFilter } from "@rilldata/web-common/features/dashboards/stores/explore-state";
-import {
- type MetricsViewSpecDimension,
- type MetricsViewSpecMeasure,
-} from "@rilldata/web-common/runtime-client";
-
-export const measureHasFilter = (
- dashData: AtLeast,
-) => {
- return (measureName: string) =>
- dashData.dashboard.dimensionThresholdFilters.some((dtf) =>
- dtf.filters.some((f) => f.measure === measureName),
- );
-};
-
-export type MeasureFilterItem = {
- dimensionName: string;
- name: string;
- label: string;
- measures?: Map;
- dimensions?: MetricsViewSpecDimension[];
- filter?: MeasureFilterEntry;
- pinned?: boolean;
- required?: boolean;
- missingRequired?: boolean;
- metricsViewNames?: string[];
-};
-
-export const getMeasureFilterItems = (
- dashData: AtLeast,
-) => {
- return (measureIdMap: Map) => {
- return getMeasureFilters(
- measureIdMap,
- dashData.dashboard.dimensionThresholdFilters,
- );
- };
-};
-
-export function getMeasureFilters(
- measureIdMap: Map,
- dimensionThresholdFilters: DimensionThresholdFilter[],
-) {
- const filteredMeasures = new Array();
- const addedMeasure = new Set();
-
- for (const dtf of dimensionThresholdFilters) {
- filteredMeasures.push(
- ...getMeasureFilterForDimension(
- measureIdMap,
- dtf.filters,
- dtf.name,
- addedMeasure,
- ),
- );
- }
-
- return filteredMeasures;
-}
-
-export function getMeasureFilterForDimension(
- measureIdMap: Map,
- filters: MeasureFilterEntry[],
- name = "",
- addedMeasure = new Set(),
-) {
- if (!filters.length) return [];
-
- const filteredMeasures = new Array();
-
- filters.forEach((filter) => {
- if (addedMeasure.has(filter.measure)) {
- return;
- }
-
- const measure = measureIdMap.get(filter.measure);
- if (!measure) {
- return;
- }
- addedMeasure.add(filter.measure);
- filteredMeasures.push({
- dimensionName: name,
- name: filter.measure,
- label: measure.displayName || measure.expression || filter.measure,
- filter,
- });
- });
-
- return filteredMeasures;
-}
-
-export const getAllMeasureFilterItems = (
- dashData: AtLeast,
-) => {
- return (
- measureFilterItems: Array,
- measureIdMap: Map,
- ) => {
- const allMeasureFilterItems = [...measureFilterItems];
-
- // if the temporary filter is a dimension filter add it
- if (
- dashData.dashboard.temporaryFilterName &&
- measureIdMap.has(dashData.dashboard.temporaryFilterName)
- ) {
- allMeasureFilterItems.push({
- dimensionName: "",
- name: dashData.dashboard.temporaryFilterName,
- label: getMeasureDisplayName(
- measureIdMap.get(dashData.dashboard.temporaryFilterName),
- ),
- });
- }
-
- return allMeasureFilterItems;
- };
-};
-
-export const measureFilterSelectors = {
- measureHasFilter,
- getMeasureFilterItems,
- getAllMeasureFilterItems,
-};
+export const measureFilterSelectors = {};
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 031ed78b1039..5be9c1cc60a8 100644
--- a/web-common/src/features/dashboards/state-managers/selectors/measures.ts
+++ b/web-common/src/features/dashboards/state-managers/selectors/measures.ts
@@ -75,24 +75,30 @@ export const filteredSimpleMeasures = ({
validMetricsView,
validExplore,
}: DashboardDataSources) => {
- return () => {
- if (!validMetricsView?.measures || !validExplore?.measures) return [];
-
- return (
- validMetricsView.measures
- .filter(
- (m) => validExplore.measures!.includes(m.name!) && isSimpleMeasure(m),
- )
- // Sort the filtered measures based on their order in validExplore.measures
- .sort(
- (a, b) =>
- validExplore.measures!.indexOf(a.name!) -
- validExplore.measures!.indexOf(b.name!),
- )
+ return () =>
+ getFilteredSimpleMeasures(
+ validMetricsView?.measures ?? [],
+ validExplore?.measures,
);
- };
};
+export function getFilteredSimpleMeasures(
+ allMeasures: MetricsViewSpecMeasure[],
+ exploreMeasures: string[] | undefined,
+) {
+ if (!exploreMeasures) return [];
+
+ return (
+ allMeasures
+ .filter((m) => exploreMeasures.includes(m.name!) && isSimpleMeasure(m))
+ // Sort the filtered measures based on their order in validExplore.measures
+ .sort(
+ (a, b) =>
+ exploreMeasures.indexOf(a.name!) - exploreMeasures.indexOf(b.name!),
+ )
+ );
+}
+
export const isSimpleMeasure = (measure: MetricsViewSpecMeasure) =>
!measure.window &&
measure.type !== MetricsViewSpecMeasureType.MEASURE_TYPE_TIME_COMPARISON;
diff --git a/web-common/src/features/dashboards/state-managers/state-managers.ts b/web-common/src/features/dashboards/state-managers/state-managers.ts
index f9f9036cd15b..ffc20be23cd5 100644
--- a/web-common/src/features/dashboards/state-managers/state-managers.ts
+++ b/web-common/src/features/dashboards/state-managers/state-managers.ts
@@ -33,6 +33,8 @@ import {
contextColWidthDefaults,
type ContextColWidths,
} from "../leaderboard-context-column";
+import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts";
+import { ExploreDashboardConfigProvider } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts";
export type StateManagers = {
runtimeClient: RuntimeClient;
@@ -65,6 +67,8 @@ export type StateManagers = {
*/
contextColumnWidths: Writable;
defaultExploreState: Readable;
+ expressionFilterManager: ExpressionFilterManager;
+ cleanup: () => void;
};
export const DEFAULT_STORE_KEY = Symbol("state-managers");
@@ -163,6 +167,23 @@ export function createStateManagers({
},
);
+ const dashboardProvider = new ExploreDashboardConfigProvider(
+ runtimeClient,
+ exploreName,
+ );
+ const expressionFilterManager = new ExpressionFilterManager(
+ dashboardProvider.metricsViewsProvider,
+ dashboardProvider.yamlConfigProvider,
+ );
+
+ const stateChangeUnsub = expressionFilterManager.on("state-changed", () => {
+ metricsExplorerStore.mergePartialExplorerEntity(
+ exploreName,
+ {},
+ expressionFilterManager,
+ );
+ });
+
return {
runtimeClient,
metricsViewName: metricsViewNameStore,
@@ -191,5 +212,11 @@ export function createStateManagers({
}),
contextColumnWidths,
defaultExploreState,
+ expressionFilterManager,
+ cleanup: () => {
+ dashboardProvider.cleanup?.();
+ dashboardProvider.metricsViewsProvider.cleanup();
+ stateChangeUnsub();
+ },
};
}
diff --git a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.spec.ts b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.spec.ts
index 1a0086c7b0b8..d4a590a0321c 100644
--- a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.spec.ts
+++ b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.spec.ts
@@ -1,7 +1,3 @@
-import {
- MeasureFilterOperation,
- MeasureFilterType,
-} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options";
import { AdvancedMeasureCorrector } from "@rilldata/web-common/features/dashboards/stores/AdvancedMeasureCorrector";
import { getFullInitExploreState } from "@rilldata/web-common/features/dashboards/stores/dashboard-store-defaults";
import { getInitExploreStateForTest } from "@rilldata/web-common/features/dashboards/stores/test-data/helpers";
@@ -20,7 +16,6 @@ import {
AD_BIDS_IMPRESSIONS_MEASURE_NO_GRAIN,
AD_BIDS_IMPRESSIONS_MEASURE_WINDOW,
AD_BIDS_METRICS_INIT,
- AD_BIDS_PUBLISHER_DIMENSION,
AD_BIDS_TIMESTAMP_DIMENSION,
} from "./test-data/data";
@@ -75,26 +70,11 @@ describe("AdvancedMeasureCorrector", () => {
getInitExploreStateForTest(MetricsView, Explore),
);
dashboard.leaderboardSortByMeasureName = AD_BIDS_IMPRESSIONS_MEASURE;
- dashboard.dimensionThresholdFilters = [
- {
- name: AD_BIDS_PUBLISHER_DIMENSION,
- filters: [
- {
- measure: AD_BIDS_IMPRESSIONS_MEASURE,
- operation: MeasureFilterOperation.GreaterThan,
- type: MeasureFilterType.Value,
- value1: "10",
- value2: "",
- },
- ],
- },
- ];
AdvancedMeasureCorrector.correct(dashboard, MetricsView);
expect(dashboard.leaderboardSortByMeasureName).toEqual(
AD_BIDS_IMPRESSIONS_MEASURE,
);
- expect(dashboard.dimensionThresholdFilters[0]?.filters.length).toEqual(1);
// metrics view spec updated to make AD_BIDS_IMPRESSIONS_MEASURE an advanced measure
AdvancedMeasureCorrector.correct(dashboard, {
@@ -136,6 +116,5 @@ describe("AdvancedMeasureCorrector", () => {
expect(dashboard.leaderboardSortByMeasureName).toEqual(
AD_BIDS_IMPRESSIONS_MEASURE_NO_GRAIN,
);
- expect(dashboard.dimensionThresholdFilters.length).toEqual(0);
});
});
diff --git a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts
index f027cc438e4c..09fff3e8766f 100644
--- a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts
+++ b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts
@@ -48,26 +48,12 @@ export class AdvancedMeasureCorrector {
}
private correct() {
- this.correctFilters();
+ // Filters are owned by ExpressionFilterManager, which corrects them itself.
this.correctLeaderboards();
this.correctTimeDimensionDetails();
this.correctPivot();
}
- private correctFilters() {
- this.exploreState.dimensionThresholdFilters.forEach(
- (dimensionThreshold) => {
- dimensionThreshold.filters = dimensionThreshold.filters.filter(
- (dtf) => !this.measureIsValidForComponent(dtf.measure, false, false),
- );
- },
- );
- this.exploreState.dimensionThresholdFilters =
- this.exploreState.dimensionThresholdFilters.filter(
- (dt) => dt.filters.length,
- );
- }
-
private correctLeaderboards() {
const validLeaderboardMeasures = this.exploreState.visibleMeasures.filter(
(m) => !this.measureIsValidForComponent(m, true, false),
diff --git a/web-common/src/features/dashboards/stores/Filters.ts b/web-common/src/features/dashboards/stores/Filters.ts
deleted file mode 100644
index 1c6c98ddc6e0..000000000000
--- a/web-common/src/features/dashboards/stores/Filters.ts
+++ /dev/null
@@ -1,520 +0,0 @@
-import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants";
-import {
- getDimensionDisplayName,
- getMeasureDisplayName,
-} from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts";
-import type { MeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts";
-import { toggleDimensionFilterValue } from "@rilldata/web-common/features/dashboards/state-managers/actions/dimension-filters.ts";
-import {
- type DimensionFilterItem,
- getDimensionFilters,
-} from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimension-filters.ts";
-import { filterItemsSortFunction } from "@rilldata/web-common/features/dashboards/state-managers/selectors/filters.ts";
-import type { MeasureFilterItem } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measure-filters.ts";
-import type {
- DimensionThresholdFilter,
- ExploreState,
-} from "@rilldata/web-common/features/dashboards/stores/explore-state.ts";
-import {
- copyFilterExpression,
- createAndExpression,
- createInExpression,
- createLikeExpression,
- matchExpressionByName,
- negateExpression,
-} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts";
-import { dedupe } from "@rilldata/web-common/lib/arrayUtils.ts";
-import {
- type MetricsViewSpecMeasure,
- type V1Expression,
- V1Operation,
-} from "@rilldata/web-common/runtime-client";
-import {
- derived,
- get,
- writable,
- type Readable,
- type Writable,
-} from "svelte/store";
-import type { ExploreMetricsViewMetadata } from "@rilldata/web-common/features/dashboards/stores/ExploreMetricsViewMetadata.ts";
-
-export type FiltersState = Pick<
- ExploreState,
- | "whereFilter"
- | "dimensionsWithInlistFilter"
- | "dimensionThresholdFilters"
- | "dimensionFilterExcludeMode"
->;
-
-/**
- * Filters class encapsulates all filter related selectors and actions into a single class.
- * It has individual stores for each data point.
- *
- * This is a copy of canvas filter class without canvas related stuff.
- * TODO: refactor canvas to use this
- */
-export class Filters {
- // -------------------
- // STORES (writable)
- // -------------------
- public readonly whereFilter: Writable;
- public readonly dimensionsWithInlistFilter: Writable;
- public readonly dimensionThresholdFilters: Writable<
- Array
- >;
- public readonly dimensionFilterExcludeMode: Writable
-
+
{#if timeGranularity}
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..6185a8c69d89 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
@@ -2,7 +2,6 @@ import {
getURIRequestMeasure,
URI_DIMENSION_SUFFIX,
} from "@rilldata/web-common/features/dashboards/dashboard-utils";
-import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
import { selectedDimensionValues } from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimension-filters";
import {
createAndExpression,
@@ -39,11 +38,8 @@ import {
} from "@tanstack/svelte-query";
import { DashboardState_ActivePage } from "../../../proto/gen/rill/ui/v1/dashboard_pb";
import { dimensionSearchText } from "../stores/dashboard-stores";
-import {
- getFilterForComparedDimension,
- prepareTimeSeries,
- transformAggregateDimensionData,
-} from "./utils";
+import { prepareTimeSeries, transformAggregateDimensionData } from "./utils";
+import { getFiltersForOtherDimensions } from "@rilldata/web-common/features/dashboards/selectors.ts";
const MAX_TDD_VALUES_LENGTH = 250;
const BATCH_SIZE = 50;
@@ -149,13 +145,10 @@ export function getDimensionValuesForComparison(
measures: tddMeasures,
dimensions: [{ name: dimensionName }],
where: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- getDimensionFilterWithSearch(
- dashboardStore?.whereFilter,
- searchText,
- dimensionName,
- ),
- dashboardStore.dimensionThresholdFilters,
+ getDimensionFilterWithSearch(
+ dashboardStore?.whereFilter,
+ searchText,
+ dimensionName,
),
undefined,
),
@@ -207,10 +200,11 @@ export function getDimensionValuesForComparison(
totals: totalValues,
values: topListValues?.slice(0, MAX_TDD_VALUES_LENGTH),
uris: uriValues?.slice(0, MAX_TDD_VALUES_LENGTH),
- filter: getFilterForComparedDimension(
- dimensionName,
- dashboardStore?.whereFilter,
- ),
+ filter:
+ getFiltersForOtherDimensions(
+ dashboardStore?.whereFilter,
+ dimensionName,
+ ) ?? createAndExpression([]),
};
},
).subscribe(set);
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..48a8dd21b26c 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,4 +1,3 @@
-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";
import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
@@ -65,13 +64,7 @@ export function createMetricsViewTimeSeries(
{
metricsViewName,
measureNames: measures,
- where: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- dashboardStore.whereFilter,
- dashboardStore.dimensionThresholdFilters,
- ),
- undefined,
- ),
+ where: sanitiseExpression(dashboardStore.whereFilter, undefined),
timeStart: isComparison
? timeControls.comparisonAdjustedStart
: timeControls.adjustedStart,
@@ -246,7 +239,7 @@ export function createTimeSeriesDataStore(
}
if (primaryTotal.error) {
isError = true;
- error["totals"] = (primaryTotal.error as Error).message;
+ error["totals"] = primaryTotal.error.message;
}
const primaryIsFetching = primary.isFetching;
const primaryTotalIsFetching = primaryTotal.isFetching;
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..bc7269f04647 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,16 +1,11 @@
-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 {
- createAndExpression,
- filterExpressions,
- matchExpressionByName,
- sanitiseExpression,
-} from "@rilldata/web-common/features/dashboards/stores/filter-utils";
+import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store";
import type { V1MetricsViewAggregationResponse } from "@rilldata/web-common/runtime-client";
import { createQueryServiceMetricsViewAggregation } from "@rilldata/web-common/runtime-client";
import type { CreateQueryResult } from "@tanstack/svelte-query";
import { derived } from "svelte/store";
+import { getFiltersForOtherDimensions } from "@rilldata/web-common/features/dashboards/selectors.ts";
export function createTotalsForMeasure(
ctx: StateManagers,
@@ -25,13 +20,7 @@ export function createTotalsForMeasure(
{
metricsView: metricsViewName,
measures: measures.map((measure) => ({ name: measure })),
- where: sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- dashboard.whereFilter,
- dashboard.dimensionThresholdFilters,
- ),
- undefined,
- ),
+ where: sanitiseExpression(dashboard.whereFilter, undefined),
timeRange: {
start: isComparison
? timeControls?.comparisonTimeStart
@@ -61,18 +50,9 @@ export function createUnfilteredTotalsForMeasure(
return derived(
[ctx.metricsViewName, useTimeControlStore(ctx), ctx.dashboardStore],
([metricsViewName, timeControls, dashboard], set) => {
- const filter = sanitiseExpression(
- mergeDimensionAndMeasureFilters(
- dashboard.whereFilter,
- dashboard.dimensionThresholdFilters,
- ),
- undefined,
- );
+ const filter = sanitiseExpression(dashboard.whereFilter, undefined);
- const updatedFilter = filterExpressions(
- filter || createAndExpression([]),
- (e) => !matchExpressionByName(e, dimensionName),
- );
+ const updatedFilter = getFiltersForOtherDimensions(filter, dimensionName);
createQueryServiceMetricsViewAggregation(
ctx.runtimeClient,
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..a6f4e5f1d221 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,3 @@
-import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
import { toPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param";
import {
type PivotChipData,
@@ -109,14 +108,10 @@ export function convertPartialExploreStateToUrlParams(
}
if ("whereFilter" in partialExploreState) {
- const expr = mergeDimensionAndMeasureFilters(
- partialExploreState.whereFilter,
- partialExploreState.dimensionThresholdFilters ?? [],
- );
let filterParam = "";
- if (expr && expr?.cond?.exprs?.length) {
+ if (partialExploreState.whereFilter?.cond?.exprs?.length) {
filterParam = convertExpressionToFilterParam(
- expr,
+ partialExploreState.whereFilter,
partialExploreState.dimensionsWithInlistFilter,
);
}
diff --git a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts
index 6df0c4e0dfd6..bce116eeeb7e 100644
--- a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts
+++ b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts
@@ -1,4 +1,3 @@
-import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
import { fromPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param";
import {
type PivotChipData,
@@ -75,11 +74,8 @@ export function convertPresetToExploreState(
}
if (preset.where) {
- const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter(
- preset.where,
- );
- partialExploreState.whereFilter = dimensionFilters;
- partialExploreState.dimensionThresholdFilters = dimensionThresholdFilters;
+ partialExploreState.whereFilter = preset.where;
+ partialExploreState.dimensionThresholdFilters = [];
}
if (preset.dimensionsWithInlistFilter) {
partialExploreState.dimensionsWithInlistFilter =
diff --git a/web-common/src/features/dashboards/url-state/invalid-url-state-variations.spec.ts b/web-common/src/features/dashboards/url-state/invalid-url-state-variations.spec.ts
index 2b3fe471f59d..9976b5b2b961 100644
--- a/web-common/src/features/dashboards/url-state/invalid-url-state-variations.spec.ts
+++ b/web-common/src/features/dashboards/url-state/invalid-url-state-variations.spec.ts
@@ -9,6 +9,7 @@ import {
AD_BIDS_EXPLORE_NAME,
AD_BIDS_METRICS_3_MEASURES_DIMENSIONS,
AD_BIDS_METRICS_INIT,
+ AD_BIDS_METRICS_NAME,
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";
@@ -16,11 +17,18 @@ import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboard
import {
applyURLToExploreState,
getCleanMetricsExploreForAssertion,
-} from "@rilldata/web-common/features/dashboards/url-state/url-state-variations.spec";
+ useTestFilterManager,
+} from "@rilldata/web-common/features/dashboards/url-state/test/url-state-test-utils";
import type { DashboardTimeControls } from "@rilldata/web-common/lib/time/types";
import { DashboardState_ActivePage } from "@rilldata/web-common/proto/gen/rill/ui/v1/dashboard_pb";
import { beforeEach, describe, expect, it } from "vitest";
+// The filter manager validates the filter param against the metrics view specs, so an invalid
+// filter has to be invalid for it as well.
+const getFilterManager = useTestFilterManager({
+ [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_3_MEASURES_DIMENSIONS,
+});
+
const TestCases: {
title: string;
url: string;
@@ -157,6 +165,7 @@ describe("Invalid Human readable URL State", () => {
new URL(url),
AD_BIDS_EXPLORE_INIT,
defaultExplorePreset,
+ getFilterManager(),
);
expect(errorsFromUrl.map((e) => e.message)).toEqual(errors);
const currentState = getCleanMetricsExploreForAssertion();
diff --git a/web-common/src/features/dashboards/url-state/test/url-state-test-utils.ts b/web-common/src/features/dashboards/url-state/test/url-state-test-utils.ts
new file mode 100644
index 000000000000..efb082ae687b
--- /dev/null
+++ b/web-common/src/features/dashboards/url-state/test/url-state-test-utils.ts
@@ -0,0 +1,126 @@
+import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts";
+import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores";
+import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state";
+import {
+ AD_BIDS_EXPLORE_NAME,
+ AD_BIDS_METRICS_VIEW,
+} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
+import { decompressUrlParams } from "@rilldata/web-common/features/dashboards/url-state/compression";
+import { convertURLSearchParamsToExploreState } from "@rilldata/web-common/features/dashboards/url-state/convertURLSearchParamsToExploreState";
+import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params";
+import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import {
+ createInEffectRoot,
+ createTestMetricsViewsProvider,
+ type MetricsViewSpecs,
+ useMetricsViewMocks,
+} from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts";
+import { ALL_TIME_RANGE_ALIAS } from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls";
+import type { DashboardTimeControls } from "@rilldata/web-common/lib/time/types";
+import type {
+ V1ExplorePreset,
+ V1ExploreSpec,
+} from "@rilldata/web-common/runtime-client";
+import { deepClone } from "@vitest/utils/helpers";
+import { get } from "svelte/store";
+import { afterAll, afterEach, beforeAll, beforeEach } from "vitest";
+
+/**
+ * Serves `specs` from ListResources and hands each test its own ExpressionFilterManager over them.
+ *
+ * The specs are read-only, so a single MetricsViewsProvider serves the whole file. The filter
+ * manager holds the filter state though, so it is rebuilt for every test.
+ */
+export function useTestFilterManager(specs: MetricsViewSpecs) {
+ useMetricsViewMocks(specs);
+
+ let metricsViewsProvider: MetricsViewsProvider;
+ let destroyProvider: () => void;
+ let filterManager: ExpressionFilterManager;
+ let destroyFilterManager: () => void;
+
+ beforeAll(async () => {
+ const provider = await createTestMetricsViewsProvider(Object.keys(specs));
+ metricsViewsProvider = provider.value;
+ destroyProvider = provider.destroy;
+ });
+
+ beforeEach(() => {
+ const created = createInEffectRoot(
+ () =>
+ new ExpressionFilterManager(
+ metricsViewsProvider,
+ new YAMLConfigProvider(),
+ ),
+ );
+ filterManager = created.value;
+ destroyFilterManager = created.destroy;
+ });
+
+ afterEach(() => destroyFilterManager());
+
+ afterAll(() => destroyProvider());
+
+ return () => filterManager;
+}
+
+export function applyURLToExploreState(
+ url: URL,
+ exploreSpec: V1ExploreSpec,
+ defaultExplorePreset: V1ExplorePreset,
+ filterManager: ExpressionFilterManager,
+) {
+ // convertURLSearchParamsToExploreState expands the compressed params itself,
+ // but the filter manager only looks for the filter params, so expand them here as well.
+ filterManager.setUrlParams(expandCompressedParams(url.searchParams));
+
+ const { partialExploreState: partialExploreStateDefaultUrl, errors } =
+ convertURLSearchParamsToExploreState(
+ url.searchParams,
+ AD_BIDS_METRICS_VIEW,
+ exploreSpec,
+ defaultExplorePreset,
+ );
+ metricsExplorerStore.mergePartialExplorerEntity(
+ AD_BIDS_EXPLORE_NAME,
+ partialExploreStateDefaultUrl,
+ filterManager,
+ );
+ return errors;
+}
+
+// cleans the metrics explore of any state that is not stored or restored from url state
+export function getCleanMetricsExploreForAssertion() {
+ // clone the existing state so that any mutations do affect the copy during assertion
+ const cleanedState = deepClone(
+ get(metricsExplorerStore).entities[AD_BIDS_EXPLORE_NAME],
+ ) as Partial;
+
+ delete cleanedState.name;
+ delete cleanedState.proto;
+ delete cleanedState.dimensionFilterExcludeMode;
+ delete cleanedState.temporaryFilterName;
+ delete cleanedState.contextColumnWidths;
+ if (cleanedState.selectedTimeRange) {
+ cleanedState.selectedTimeRange = {
+ name: cleanedState.selectedTimeRange?.name ?? ALL_TIME_RANGE_ALIAS,
+ interval: cleanedState.selectedTimeRange?.interval,
+ } as DashboardTimeControls;
+ }
+ delete cleanedState.lastDefinedScrubRange;
+
+ // TODO
+ delete cleanedState.leaderboardContextColumn;
+
+ return cleanedState;
+}
+
+function expandCompressedParams(searchParams: URLSearchParams) {
+ const compressedParams = searchParams.get(
+ ExploreStateURLParams.GzippedParams,
+ );
+ if (!compressedParams) return searchParams;
+
+ return new URLSearchParams(decompressUrlParams(compressedParams));
+}
diff --git a/web-common/src/features/dashboards/url-state/url-state-variations.spec.ts b/web-common/src/features/dashboards/url-state/url-state-variations.spec.ts
index 570e779418d5..70e950f36dc0 100644
--- a/web-common/src/features/dashboards/url-state/url-state-variations.spec.ts
+++ b/web-common/src/features/dashboards/url-state/url-state-variations.spec.ts
@@ -8,6 +8,7 @@ import {
AD_BIDS_EXPLORE_NAME,
AD_BIDS_METRICS_3_MEASURES_DIMENSIONS_WITH_TIME,
AD_BIDS_METRICS_VIEW,
+ AD_BIDS_NAME,
AD_BIDS_PIVOT_PRESET,
AD_BIDS_PRESET,
AD_BIDS_PUBLISHER_DIMENSION,
@@ -76,6 +77,11 @@ import { getTimeControlState } from "@rilldata/web-common/features/dashboards/ti
import { getCleanedUrlParamsForGoto } from "@rilldata/web-common/features/dashboards/url-state/convert-partial-explore-state-to-url-params";
import { getRillDefaultExploreUrlParams } from "@rilldata/web-common/features/dashboards/url-state/get-rill-default-explore-url-params";
import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/getDefaultExplorePreset";
+import {
+ applyURLToExploreState,
+ getCleanMetricsExploreForAssertion,
+ useTestFilterManager,
+} from "@rilldata/web-common/features/dashboards/url-state/test/url-state-test-utils";
import {
type DashboardTimeControls,
TimeComparisonOption,
@@ -86,14 +92,18 @@ import {
type V1ExplorePreset,
type V1ExploreSpec,
} from "@rilldata/web-common/runtime-client";
-import { deepClone } from "@vitest/utils/helpers";
import { get } from "svelte/store";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { ALL_TIME_RANGE_ALIAS } from "../time-controls/new-time-controls";
import { convertURLSearchParamsToExploreState } from "./convertURLSearchParamsToExploreState";
vi.stubEnv("TZ", "UTC");
+// Filters live in the ExpressionFilterManager rather than in explore state, so the tests need the
+// specs of the metrics view backing AD_BIDS_EXPLORE to build the filter chips.
+const getFilterManager = useTestFilterManager({
+ [AD_BIDS_NAME]: AD_BIDS_METRICS_VIEW,
+});
+
const TestCases: {
title: string;
mutations: TestDashboardMutation[];
@@ -113,13 +123,13 @@ const TestCases: {
AD_BIDS_APPLY_IMP_COUNTRY_BETWEEN_MEASURE_FILTER,
],
expectedSearch:
- "f=publisher+IN+LIST+%28%27Facebook%27%2C%27Google%27%29+AND+domain+LIKE+%27%25%25oo%25%25%27+AND+country+having+%28%28bid_price+GT+10+AND+bid_price+LT+20%29%29",
+ "f=publisher+IN+LIST+%28%27Facebook%27%2C%27Google%27%29+AND+domain+LIKE+%27%25%25oo%25%25%27+AND+country+having+%28bid_price+GT+10+AND+bid_price+LT+20%29",
},
{
title: "Not-between measure filter",
mutations: [AD_BIDS_APPLY_IMP_COUNTRY_NOT_BETWEEN_MEASURE_FILTER],
expectedSearch:
- "f=country+having+%28%28bid_price+LTE+10+OR+bid_price+GTE+20%29%29",
+ "f=country+having+%28bid_price+LTE+10+OR+bid_price+GTE+20%29",
},
{
@@ -590,7 +600,11 @@ describe("Human readable URL state variations", () => {
AD_BIDS_TIME_RANGE_SUMMARY.timeRangeSummary,
);
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ mutations,
+ getFilterManager(),
+ );
// load url params with updated metrics state
const updateUrlParams = getCleanedUrlParamsForGoto(
@@ -613,6 +627,7 @@ describe("Human readable URL state variations", () => {
defaultUrl,
explore,
defaultExplorePreset,
+ getFilterManager(),
);
expect(errors.length).toEqual(0);
const currentState = getCleanMetricsExploreForAssertion();
@@ -651,7 +666,11 @@ describe("Human readable URL state variations", () => {
);
const initState = getCleanMetricsExploreForAssertion();
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, mutations);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ mutations,
+ getFilterManager(),
+ );
const curState = getCleanMetricsExploreForAssertion() as ExploreState;
const url = new URL("http://localhost");
@@ -709,12 +728,16 @@ describe("Human readable URL state variations", () => {
AD_BIDS_TIME_RANGE_SUMMARY.timeRangeSummary,
);
- await applyMutationsToDashboard(AD_BIDS_EXPLORE_NAME, [
- AD_BIDS_APPLY_LARGE_FILTERS,
- AD_BIDS_SET_P4W_TIME_RANGE_FILTER,
- AD_BIDS_SET_PREVIOUS_PERIOD_COMPARE_TIME_RANGE_FILTER,
- AD_BIDS_OPEN_PIVOT_WITH_ALL_FIELDS,
- ]);
+ await applyMutationsToDashboard(
+ AD_BIDS_EXPLORE_NAME,
+ [
+ AD_BIDS_APPLY_LARGE_FILTERS,
+ AD_BIDS_SET_P4W_TIME_RANGE_FILTER,
+ AD_BIDS_SET_PREVIOUS_PERIOD_COMPARE_TIME_RANGE_FILTER,
+ AD_BIDS_OPEN_PIVOT_WITH_ALL_FIELDS,
+ ],
+ getFilterManager(),
+ );
// load url params with updated metrics state
const url = new URL("http://localhost");
@@ -737,9 +760,15 @@ describe("Human readable URL state variations", () => {
new URL("http://localhost"),
AD_BIDS_EXPLORE,
defaultExplorePreset,
+ getFilterManager(),
);
// reapply the compressed url
- applyURLToExploreState(url, AD_BIDS_EXPLORE, defaultExplorePreset);
+ applyURLToExploreState(
+ url,
+ AD_BIDS_EXPLORE,
+ defaultExplorePreset,
+ getFilterManager(),
+ );
const currentState = getCleanMetricsExploreForAssertion();
expect(currentState.selectedTimeRange?.name).toEqual(
@@ -809,7 +838,12 @@ describe("Human readable URL state variations", () => {
// Deserialize URL back to state (simulates page refresh)
const url = new URL("http://localhost");
url.search = urlParams.toString();
- applyURLToExploreState(url, explore, defaultExplorePreset);
+ applyURLToExploreState(
+ url,
+ explore,
+ defaultExplorePreset,
+ getFilterManager(),
+ );
const stateAfterRoundtrip = getCleanMetricsExploreForAssertion();
expect(stateAfterRoundtrip.pivot?.sorting).toEqual(
@@ -848,7 +882,12 @@ describe("Human readable URL state variations", () => {
// Deserialize URL back to state (simulates page refresh)
const url = new URL("http://localhost");
url.search = urlParams.toString();
- applyURLToExploreState(url, explore, defaultExplorePreset);
+ applyURLToExploreState(
+ url,
+ explore,
+ defaultExplorePreset,
+ getFilterManager(),
+ );
const stateAfterRoundtrip = getCleanMetricsExploreForAssertion();
expect(stateAfterRoundtrip.pivot?.sorting).toEqual(
@@ -857,48 +896,3 @@ describe("Human readable URL state variations", () => {
});
});
});
-
-export function applyURLToExploreState(
- url: URL,
- exploreSpec: V1ExploreSpec,
- defaultExplorePreset: V1ExplorePreset,
-) {
- const { partialExploreState: partialExploreStateDefaultUrl, errors } =
- convertURLSearchParamsToExploreState(
- url.searchParams,
- AD_BIDS_METRICS_VIEW,
- exploreSpec,
- defaultExplorePreset,
- );
- metricsExplorerStore.mergePartialExplorerEntity(
- AD_BIDS_EXPLORE_NAME,
- partialExploreStateDefaultUrl,
- );
- return errors;
-}
-
-// cleans the metrics explore of any state that is not stored or restored from url state
-export function getCleanMetricsExploreForAssertion() {
- // clone the existing state so that any mutations do affect the copy during assertion
- const cleanedState = deepClone(
- get(metricsExplorerStore).entities[AD_BIDS_EXPLORE_NAME],
- ) as Partial;
-
- delete cleanedState.name;
- delete cleanedState.proto;
- delete cleanedState.dimensionFilterExcludeMode;
- delete cleanedState.temporaryFilterName;
- delete cleanedState.contextColumnWidths;
- if (cleanedState.selectedTimeRange) {
- cleanedState.selectedTimeRange = {
- name: cleanedState.selectedTimeRange?.name ?? ALL_TIME_RANGE_ALIAS,
- interval: cleanedState.selectedTimeRange?.interval,
- } as DashboardTimeControls;
- }
- delete cleanedState.lastDefinedScrubRange;
-
- // TODO
- delete cleanedState.leaderboardContextColumn;
-
- return cleanedState;
-}
diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte
index 2e78e25f02a6..1aad030740fb 100644
--- a/web-common/src/features/dashboards/workspace/Dashboard.svelte
+++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte
@@ -56,26 +56,24 @@
pivot: { showPivot },
},
dashboardStore,
+ expressionFilterManager,
} = StateManagers;
const { adminServer, cloudDataViewer, readOnly } = featureFlags;
const timeControlsStore = useTimeControlStore(StateManagers);
- onMount(() => {
- // Github star nudge is Rill developer only.
- // Nudge on dashboard render.
- if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff();
- });
-
let exploreContainerWidth: number;
let exploreContainerHeight: number;
let resizing = false;
const client = useRuntimeClient();
- $: ({ whereFilter, dimensionThresholdFilters, selectedTimeDimension } =
- $dashboardStore);
+ $: ({ selectedTimeDimension } = $dashboardStore);
+ const filterStore =
+ expressionFilterManager.getExprStoreForMetricsView(metricsViewName);
+ $: dimensionOnlyFilter = $filterStore?.dimensionOnlyExpr;
+ $: whereFilter = $filterStore?.expr;
$: extraLeftPadding = !$navigationOpen;
@@ -152,6 +150,12 @@
// Publish the resolved theme to the shared store for external components (e.g., chat in layout)
$: activeDashboardTheme.set($theme);
+ onMount(() => {
+ // Github star nudge is Rill developer only.
+ // Nudge on dashboard render.
+ if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff();
+ });
+
// Clear the active theme when this dashboard is destroyed
onDestroy(() => activeDashboardTheme.set(undefined));
@@ -213,11 +217,17 @@
{#if hasTimeSeries}
{:else}
-
+
{/if}
{/key}
@@ -265,7 +275,6 @@
dimension={selectedDimension}
{metricsViewName}
{whereFilter}
- {dimensionThresholdFilters}
{timeRange}
{comparisonTimeRange}
{timeControlsReady}
@@ -276,7 +285,6 @@
1 ||
- dashboard.dimensionThresholdFilters.length > 0
- ) {
- // If there are dimension threshold and having filter we just add a subquery in where filter.
- // This will be marked as "advanced filter" that is not editable.
- // TODO: find a way to merge having filter into dimension threshold
- const extraFilter = createSubQueryExpression(
+ } else {
+ // Measure filters are stored as a subquery on the dimension within the where filter.
+ havingFilter = createSubQueryExpression(
dimension,
getAllIdentifiers(req.having),
req.having,
);
- if (dashboard.whereFilter?.cond?.exprs?.length) {
- dashboard.whereFilter = createAndExpression([
- dashboard.whereFilter,
- extraFilter,
- ]);
- } else {
- dashboard.whereFilter = extraFilter;
- }
- } else {
- dashboard.dimensionThresholdFilters = [
- {
- name: dimension,
- filters:
- req.having?.cond?.exprs
- ?.map(mapExprToMeasureFilter)
- .filter((f): f is NonNullable => f != null) ?? [],
- },
- ];
}
+
+ dashboard.whereFilter =
+ mergeFilters(
+ dashboard.whereFilter ?? createAndExpression([]),
+ createAndExpression([havingFilter]),
+ ) ?? createAndExpression([]);
}
// everything after this can be loaded from the dashboard state if present
diff --git a/web-common/src/features/explores/explore-link/explore-state-transformer.ts b/web-common/src/features/explores/explore-link/explore-state-transformer.ts
index b1af95eb9ecb..40abf2b1a081 100644
--- a/web-common/src/features/explores/explore-link/explore-state-transformer.ts
+++ b/web-common/src/features/explores/explore-link/explore-state-transformer.ts
@@ -1,4 +1,3 @@
-import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state";
import type { TimeAndFilterStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store";
import { TimeRangePreset } from "@rilldata/web-common/lib/time/types";
@@ -12,11 +11,7 @@ export function transformTimeAndFiltersToExploreState(
const exploreState: Partial = {};
if (timeAndFilterStore.where) {
- const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter(
- timeAndFilterStore.where,
- );
- exploreState.whereFilter = dimensionFilters;
- exploreState.dimensionThresholdFilters = dimensionThresholdFilters;
+ exploreState.whereFilter = timeAndFilterStore.where;
}
if (timeAndFilterStore.timeRangeState) {
diff --git a/web-common/src/features/exports/export-filters.spec.ts b/web-common/src/features/exports/export-filters.spec.ts
index e8ed94ab1834..d92321516fbf 100644
--- a/web-common/src/features/exports/export-filters.spec.ts
+++ b/web-common/src/features/exports/export-filters.spec.ts
@@ -26,7 +26,6 @@ describe("buildWhereParamForDimensionTableAndTDDExports", () => {
const result = buildWhereParamForDimensionTableAndTDDExports(
params.whereFilter,
- params.measureFilters,
params.dimensionName,
params.searchText,
);
@@ -42,7 +41,6 @@ describe("buildWhereParamForDimensionTableAndTDDExports", () => {
const result = buildWhereParamForDimensionTableAndTDDExports(
params.whereFilter,
- params.measureFilters,
params.dimensionName,
params.searchText,
);
@@ -59,7 +57,6 @@ describe("buildWhereParamForDimensionTableAndTDDExports", () => {
const result = buildWhereParamForDimensionTableAndTDDExports(
params.whereFilter,
- params.measureFilters,
params.dimensionName,
params.searchText,
);
diff --git a/web-common/src/features/exports/export-filters.ts b/web-common/src/features/exports/export-filters.ts
index 10d41f9c7cbf..bd34d6d3a873 100644
--- a/web-common/src/features/exports/export-filters.ts
+++ b/web-common/src/features/exports/export-filters.ts
@@ -1,8 +1,6 @@
import type { V1Expression } from "@rilldata/web-admin/client/gen/index.schemas";
import { getDimensionFilterWithSearch } from "../dashboards/dimension-table/dimension-table-utils";
-import { mergeDimensionAndMeasureFilters } from "../dashboards/filters/measure-filters/measure-filter-utils";
import { sanitiseExpression } from "../dashboards/stores/filter-utils";
-import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state";
/**
* If there's input in the search field, then all search results will be included in the export.
@@ -10,7 +8,6 @@ import type { DimensionThresholdFilter } from "web-common/src/features/dashboard
*/
export function buildWhereParamForDimensionTableAndTDDExports(
whereFilter: V1Expression,
- dimensionThresholdFilters: DimensionThresholdFilter[],
dimensionName: string,
searchText: string,
) {
@@ -25,10 +22,5 @@ export function buildWhereParamForDimensionTableAndTDDExports(
dimensionFilter = whereFilter;
}
- const where = mergeDimensionAndMeasureFilters(
- dimensionFilter,
- dimensionThresholdFilters,
- );
- const sanitisedWhere = sanitiseExpression(where, undefined);
- return sanitisedWhere;
+ return sanitiseExpression(dimensionFilter, undefined);
}
diff --git a/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte b/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte
index 70c80f5391cb..098ec473618c 100644
--- a/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte
+++ b/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte
@@ -1,18 +1,20 @@
{/if}
- {#if hasFilters}
-
+ {#if expressionFilterManager.hasSomeFilter}
+
{/if}
diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts
new file mode 100644
index 000000000000..2578d60d17af
--- /dev/null
+++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts
@@ -0,0 +1,80 @@
+import {
+ AD_BIDS_BID_PRICE_MEASURE,
+ AD_BIDS_DOMAIN_DIMENSION,
+ AD_BIDS_IMPRESSIONS_MEASURE,
+ AD_BIDS_METRICS_INIT,
+ AD_BIDS_METRICS_NAME,
+ AD_BIDS_PUBLISHER_DIMENSION,
+} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
+import {
+ createTestMetricsViewsProvider,
+ useMetricsViewMocks,
+} from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts";
+import { describe, expect, it } from "vitest";
+
+const AD_BIDS_MIRROR_METRICS_NAME = "AdBids_mirror_metrics";
+
+useMetricsViewMocks({
+ [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT,
+ // Shares the publisher dimension and the impressions measure with AdBids.
+ [AD_BIDS_MIRROR_METRICS_NAME]: {
+ ...AD_BIDS_METRICS_INIT,
+ dimensions: [{ name: AD_BIDS_PUBLISHER_DIMENSION }],
+ measures: [{ name: AD_BIDS_IMPRESSIONS_MEASURE }],
+ },
+});
+
+describe("MetricsViewsProvider", () => {
+ it("loads the spec for each metrics view", async () => {
+ const { value: provider, destroy } = await createTestMetricsViewsProvider([
+ AD_BIDS_METRICS_NAME,
+ ]);
+
+ // Not an exact match: the transport fills in proto default values.
+ expect(provider.specs[AD_BIDS_METRICS_NAME]).toMatchObject(
+ AD_BIDS_METRICS_INIT,
+ );
+ expect(provider.measures.map((m) => m.name)).toEqual([
+ AD_BIDS_IMPRESSIONS_MEASURE,
+ AD_BIDS_BID_PRICE_MEASURE,
+ ]);
+ expect(provider.dimensions.map((d) => d.name)).toEqual([
+ AD_BIDS_PUBLISHER_DIMENSION,
+ AD_BIDS_DOMAIN_DIMENSION,
+ ]);
+ // No time dimension, so there is no time range summary to wait for.
+ expect(provider.ready).toBe(true);
+
+ destroy();
+ });
+
+ it("maps a shared measure or dimension to every metrics view defining it", async () => {
+ const { value: provider, destroy } = await createTestMetricsViewsProvider([
+ AD_BIDS_METRICS_NAME,
+ AD_BIDS_MIRROR_METRICS_NAME,
+ ]);
+
+ expect(
+ Object.keys(provider.dimensionSpecs[AD_BIDS_PUBLISHER_DIMENSION]),
+ ).toEqual([AD_BIDS_METRICS_NAME, AD_BIDS_MIRROR_METRICS_NAME]);
+ expect(
+ Object.keys(provider.measureSpecs[AD_BIDS_IMPRESSIONS_MEASURE]),
+ ).toEqual([AD_BIDS_METRICS_NAME, AD_BIDS_MIRROR_METRICS_NAME]);
+
+ // Only AdBids defines these, so they stay single-entry.
+ expect(
+ Object.keys(provider.dimensionSpecs[AD_BIDS_DOMAIN_DIMENSION]),
+ ).toEqual([AD_BIDS_METRICS_NAME]);
+ expect(
+ Object.keys(provider.measureSpecs[AD_BIDS_BID_PRICE_MEASURE]),
+ ).toEqual([AD_BIDS_METRICS_NAME]);
+
+ // Deduped across metrics views, first definition wins.
+ expect(provider.dimensions.map((d) => d.name)).toEqual([
+ AD_BIDS_PUBLISHER_DIMENSION,
+ AD_BIDS_DOMAIN_DIMENSION,
+ ]);
+
+ destroy();
+ });
+});
diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts
new file mode 100644
index 000000000000..35af7ade301d
--- /dev/null
+++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts
@@ -0,0 +1,251 @@
+import {
+ createQueryServiceMetricsViewTimeRange,
+ createRuntimeServiceListResources,
+ type MetricsViewSpecDimension,
+ type MetricsViewSpecMeasure,
+ type V1MetricsViewSpec,
+ type V1Resource,
+ type V1TimeRangeSummary,
+} from "@rilldata/web-common/runtime-client";
+import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { isSimpleMeasure } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures.ts";
+import { Duration } from "luxon";
+import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";
+import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors.ts";
+
+export type MetricsViewName = string;
+export type DimensionName = string;
+export type MeasureName = string;
+
+/**
+ * Reactive view over a set of metrics views.
+ *
+ * Specs for every metrics view come from a single ListResources subscription.
+ * Time range summaries are fetched per metrics view, and only for the ones that have a time dimension,
+ * so the summaries arrive after the specs rather than alongside them.
+ *
+ * Measures and dimensions are exposed two ways:
+ * as deduped flat lists for pickers, and as name -> metrics view -> spec maps for callers that need to
+ * know which metrics views a given measure or dimension belongs to.
+ */
+export class MetricsViewsProvider {
+ /** Valid spec per metrics view name. Absent while the resource is loading or invalid. */
+ public specs = $state>({});
+ /** Time range summary per metrics view name. Absent for metrics views without a time dimension. */
+ public timeRangeSummaries = $state<
+ Record
+ >({});
+ /** Max queryable time range in milliseconds per metrics view name. Zero when unrestricted. */
+ public maxQueryTimeRangeMillis = $state>({});
+
+ /** Dimension spec per metrics view, keyed by dimension name (or column when unnamed). */
+ public dimensionSpecs = $state<
+ Record>
+ >({});
+ /**
+ * Measure spec per metrics view, keyed by measure name.
+ * The same measure name can be defined by more than one metrics view.
+ */
+ public measureSpecs = $state<
+ Record>
+ >({});
+
+ /** Deduped by name across metrics views; the first metrics view to define a name wins. */
+ public measures = $state([]);
+ public simpleMeasures = $state([]);
+ public dimensions = $state([]);
+
+ /** Union of the individual summaries: earliest min, latest max, latest watermark. */
+ public timeRangeSummary: V1TimeRangeSummary | undefined;
+ /** Smallest restriction across the metrics views, since it has to hold for all of them. */
+ public maxQueryTimeRange: Duration | undefined;
+ /** True once every metrics view has a spec and every time series metrics view has a summary. */
+ public ready: boolean;
+ public metricsViewNames = $state([]);
+
+ public cleanup: () => void; // TODO: ensure this is called by creators
+
+ private resources: V1Resource[] = [];
+ private readonly timeRangeUnsubs = new Map void>();
+
+ public constructor(
+ public readonly runtimeClient: RuntimeClient,
+ initMetricsViewNames: string[],
+ ) {
+ this.metricsViewNames = initMetricsViewNames;
+
+ const allResourcesQuery = createRuntimeServiceListResources(
+ runtimeClient,
+ {},
+ undefined,
+ queryClient,
+ );
+ const allResourcesUnsub = allResourcesQuery.subscribe(
+ (allResourcesResp) => {
+ this.resources = allResourcesResp.data?.resources ?? [];
+ this.processResources();
+ },
+ );
+
+ this.timeRangeSummary = $derived.by(() => {
+ let min: string | undefined;
+ let max: string | undefined;
+ let watermark: string | undefined;
+ let minTime = Infinity;
+ let maxTime = -Infinity;
+ let watermarkTime = -Infinity;
+
+ for (const metricsViewName of this.metricsViewNames) {
+ const summary = this.timeRangeSummaries[metricsViewName];
+ if (!summary) continue;
+
+ // Date.parse returns NaN for missing or malformed timestamps,
+ // and every comparison against NaN is false, so those simply never win.
+ const minCandidate = Date.parse(summary.min ?? "");
+ if (minCandidate < minTime) {
+ minTime = minCandidate;
+ min = summary.min;
+ }
+
+ const maxCandidate = Date.parse(summary.max ?? "");
+ if (maxCandidate > maxTime) {
+ maxTime = maxCandidate;
+ max = summary.max;
+ }
+
+ const watermarkCandidate = Date.parse(summary.watermark ?? "");
+ if (watermarkCandidate > watermarkTime) {
+ watermarkTime = watermarkCandidate;
+ watermark = summary.watermark;
+ }
+ }
+
+ if (!min && !max && !watermark) return undefined;
+ return { min, max, watermark };
+ });
+
+ this.maxQueryTimeRange = $derived.by(() => {
+ let smallestMillis = Infinity;
+ for (const metricsViewName of this.metricsViewNames) {
+ const millis = this.maxQueryTimeRangeMillis[metricsViewName] ?? 0;
+ if (millis > 0 && millis < smallestMillis) smallestMillis = millis;
+ }
+ return smallestMillis === Infinity
+ ? undefined
+ : Duration.fromMillis(smallestMillis);
+ });
+
+ this.ready = $derived(
+ this.metricsViewNames.every((metricsViewName) => {
+ const spec = this.specs[metricsViewName];
+ if (!spec) return false;
+ return (
+ !spec.timeDimension || !!this.timeRangeSummaries[metricsViewName]
+ );
+ }),
+ );
+
+ this.cleanup = () => {
+ allResourcesUnsub();
+ this.timeRangeUnsubs.forEach((unsub) => unsub());
+ this.timeRangeUnsubs.clear();
+ };
+ }
+
+ public setMetricsViewNames(metricsViewNames: string[]) {
+ this.metricsViewNames = metricsViewNames;
+ this.processResources();
+ }
+
+ private processResources() {
+ const specs: Record = {};
+
+ const measureSpecs: Record<
+ string,
+ Record
+ > = {};
+ const measures: MetricsViewSpecMeasure[] = [];
+ const simpleMeasures: MetricsViewSpecMeasure[] = [];
+
+ const dimensionSpecs: Record<
+ string,
+ Record
+ > = {};
+ const dimensions: MetricsViewSpecDimension[] = [];
+
+ for (const metricsViewName of this.metricsViewNames) {
+ const res = this.resources.find(
+ (resource) =>
+ resource.meta?.name?.name === metricsViewName &&
+ resource.meta?.name?.kind === ResourceKind.MetricsView,
+ );
+ const spec = res?.metricsView?.state?.validSpec;
+ if (!spec) continue;
+ specs[metricsViewName] = spec;
+
+ spec.measures?.forEach((measure) => {
+ if (!measure.name) return;
+
+ let specsForMeasure = measureSpecs[measure.name];
+ if (!specsForMeasure) {
+ specsForMeasure = measureSpecs[measure.name] = {};
+ measures.push(measure);
+ if (isSimpleMeasure(measure)) simpleMeasures.push(measure);
+ }
+ specsForMeasure[metricsViewName] = measure;
+ });
+
+ spec.dimensions?.forEach((dimension) => {
+ // Filter expressions identify an unnamed dimension by its column.
+ const dimensionName = dimension.name || dimension.column;
+ if (!dimensionName) return;
+
+ let specsForDimension = dimensionSpecs[dimensionName];
+ if (!specsForDimension) {
+ specsForDimension = dimensionSpecs[dimensionName] = {};
+ dimensions.push(dimension);
+ }
+ specsForDimension[metricsViewName] = dimension;
+ });
+
+ this.subscribeToTimeRange(metricsViewName, spec);
+ }
+
+ this.specs = specs;
+ this.measureSpecs = measureSpecs;
+ this.measures = measures;
+ this.simpleMeasures = simpleMeasures;
+ this.dimensionSpecs = dimensionSpecs;
+ this.dimensions = dimensions;
+ }
+
+ /**
+ * Starts the time range query for a metrics view the first time its spec shows up.
+ * Metrics views without a time dimension have no summary to fetch.
+ */
+ private subscribeToTimeRange(
+ metricsViewName: string,
+ spec: V1MetricsViewSpec,
+ ) {
+ if (!spec.timeDimension || this.timeRangeUnsubs.has(metricsViewName)) {
+ return;
+ }
+
+ const timeRangeQuery = createQueryServiceMetricsViewTimeRange(
+ this.runtimeClient,
+ { metricsViewName },
+ undefined,
+ queryClient,
+ );
+ this.timeRangeUnsubs.set(
+ metricsViewName,
+ timeRangeQuery.subscribe((timeRangeResp) => {
+ const summary = timeRangeResp.data?.timeRangeSummary;
+ if (summary) this.timeRangeSummaries[metricsViewName] = summary;
+ this.maxQueryTimeRangeMillis[metricsViewName] = Number(
+ timeRangeResp.data?.maxQueryTimeRangeMillis ?? 0,
+ );
+ }),
+ );
+ }
+}
diff --git a/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte b/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte
new file mode 100644
index 000000000000..a9e22a5643d9
--- /dev/null
+++ b/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte
@@ -0,0 +1,34 @@
+
diff --git a/web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts b/web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts
new file mode 100644
index 000000000000..1231e4922c67
--- /dev/null
+++ b/web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts
@@ -0,0 +1,156 @@
+import { DashboardFetchMocks } from "@rilldata/web-common/features/dashboards/dashboard-fetch-mocks";
+import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import RuntimeContextHarness from "@rilldata/web-common/features/metrics-views/providers/test/RuntimeContextHarness.svelte";
+import { waitUntil } from "@rilldata/web-common/lib/waitUtils";
+import type { V1MetricsViewSpec } from "@rilldata/web-common/runtime-client";
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { QueryClient } from "@tanstack/svelte-query";
+import { mount, unmount } from "svelte";
+
+/**
+ * Helpers for unit tests that need real metrics view specs rather than hand-stubbed ones.
+ *
+ * useMetricsViewMocks({ [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT });
+ *
+ * it("filters by publisher", async () => {
+ * const { value: provider, destroy } = await createTestMetricsViewsProvider([
+ * AD_BIDS_METRICS_NAME,
+ * ]);
+ * ...
+ * destroy();
+ * });
+ */
+
+export type MetricsViewSpecs = Record;
+
+export type RuntimeTestContext = {
+ runtimeClient: RuntimeClient;
+ queryClient: QueryClient;
+};
+
+export type RenderedInRuntimeContext = RuntimeTestContext & {
+ /** Whatever `init` returned. */
+ value: T;
+ destroy: () => void;
+};
+
+/**
+ * Serves `specs` from ListResources for the duration of the test file.
+ *
+ * Call this at module or `describe` scope, since it registers the `beforeAll` hook
+ * that stubs `fetch`. The returned mocks can be used to add responses for other
+ * endpoints, such as aggregation queries backing dimension value lists.
+ */
+export function useMetricsViewMocks(specs: MetricsViewSpecs) {
+ const mocks = DashboardFetchMocks.useDashboardFetchMocks();
+ for (const [metricsViewName, spec] of Object.entries(specs)) {
+ mocks.mockMetricsView(metricsViewName, spec);
+ }
+ return mocks;
+}
+
+/**
+ * Runs `init` inside an effect root, which is all a class needs when it only calls
+ * `$effect` in its constructor, such as ExpressionFilterManager. Classes that also
+ * call `createQuery` need a QueryClient context, so use {@link renderInRuntimeContext}
+ * for those.
+ */
+export function createInEffectRoot(init: () => T): {
+ value: T;
+ destroy: () => void;
+} {
+ let value: T | undefined;
+ const destroy = $effect.root(() => {
+ value = init();
+ });
+ return { value: value as T, destroy };
+}
+
+/**
+ * Runs `init` inside a mounted component so that it has a QueryClient context and an
+ * effect owner. Classes like MetricsViewsProvider call `createQuery` in their
+ * constructors, so they can only be built here and not directly from a test body.
+ *
+ * The caller owns the returned `destroy`; call it once the test is done to unmount
+ * the harness and stop the queries.
+ */
+export function renderInRuntimeContext(
+ init: (ctx: RuntimeTestContext) => T,
+): RenderedInRuntimeContext {
+ const runtimeClient = new RuntimeClient({
+ host: "http://localhost",
+ instanceId: "test",
+ });
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ networkMode: "always",
+ },
+ },
+ });
+
+ let value: T | undefined;
+ const component = mount(RuntimeContextHarness, {
+ target: document.createElement("div"),
+ props: {
+ queryClient,
+ runtimeClient,
+ init: () => {
+ value = init({ runtimeClient, queryClient });
+ },
+ },
+ });
+
+ return {
+ value: value as T,
+ runtimeClient,
+ queryClient,
+ destroy: () => {
+ void unmount(component);
+ queryClient.clear();
+ runtimeClient.dispose();
+ },
+ };
+}
+
+/**
+ * Creates a MetricsViewsProvider for `metricsViewNames` and resolves once the specs
+ * mocked by {@link useMetricsViewMocks} have landed.
+ */
+export async function createTestMetricsViewsProvider(
+ metricsViewNames: string[],
+): Promise> {
+ const rendered = renderInRuntimeContext(({ runtimeClient }) => {
+ return new MetricsViewsProvider(runtimeClient, metricsViewNames);
+ });
+
+ await waitForMetricsViewSpecs(rendered.value, metricsViewNames);
+
+ return rendered;
+}
+
+/** Resolves once every requested metrics view has a spec, or throws on timeout. */
+export async function waitForMetricsViewSpecs(
+ metricsViewsProvider: MetricsViewsProvider,
+ metricsViewNames: string[],
+ timeout = 5000,
+) {
+ const loaded = await waitUntil(
+ () => metricsViewNames.every((name) => !!metricsViewsProvider.specs[name]),
+ timeout,
+ 10,
+ );
+ if (!loaded) {
+ const missing = metricsViewNames.filter(
+ (name) => !metricsViewsProvider.specs[name],
+ );
+ throw new Error(
+ `Timed out waiting for metrics view specs: ${missing.join(", ")}. ` +
+ `Did the test call useMetricsViewMocks with these metrics views?`,
+ );
+ }
+}
diff --git a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte
index 994737ec28c9..209186a0bc6d 100644
--- a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte
+++ b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte
@@ -5,7 +5,6 @@
import MultiInput from "@rilldata/web-common/components/forms/MultiInput.svelte";
import FormSection from "@rilldata/web-common/components/forms/FormSection.svelte";
import { getHasSlackConnection } from "@rilldata/web-common/features/alerts/delivery-tab/notifiers-utils";
- import type { Filters } from "@rilldata/web-common/features/dashboards/stores/Filters.ts";
import type { TimeControls } from "@rilldata/web-common/features/dashboards/stores/TimeControls.ts";
import FiltersForm from "@rilldata/web-common/features/scheduled-reports/FiltersForm.svelte";
import RowsAndColumnsForm from "@rilldata/web-common/features/scheduled-reports/fields/RowsAndColumnsForm.svelte";
@@ -24,6 +23,7 @@
import Select from "../../components/forms/Select.svelte";
import Checkbox from "../../components/forms/Checkbox.svelte";
import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+ import type { ExpressionFilterManager } from "../dashboards/filters/ExpressionFilterManager.svelte.ts";
import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts";
import {
ResourceKind,
@@ -39,13 +39,13 @@
export let errors: SuperFormErrors;
export let submit: () => void;
export let enhance;
- // Exactly one of exploreName and canvasName is non-empty; canvasName selects the canvas PDF variant of the form.
+ export let metricsViewName: string;
export let exploreName: string;
export let canvasName: string = "";
// Canvas state (URL search string) to display instead of the page URL; set when
// editing a report so the filter bar shows the report's captured state.
export let canvasStateOverride: string | undefined = undefined;
- export let filters: Filters | undefined = undefined;
+ export let filters: ExpressionFilterManager | undefined = undefined;
export let timeControls: TimeControls | undefined = undefined;
const RUN_AS_OPTIONS = [
@@ -245,7 +245,13 @@
id="filters"
capitalize={false}
/>
-
+
{/if}
diff --git a/web-common/src/features/scheduled-reports/FiltersForm.svelte b/web-common/src/features/scheduled-reports/FiltersForm.svelte
index 3aa46b4e2261..22c340e24b4e 100644
--- a/web-common/src/features/scheduled-reports/FiltersForm.svelte
+++ b/web-common/src/features/scheduled-reports/FiltersForm.svelte
@@ -1,18 +1,9 @@