From 2c15dc4794edff4bc64ee8fd34c4d420c70c0d1f Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 22 Jul 2026 20:11:16 +0530 Subject: [PATCH 01/21] reafc: move to unified filter component --- .../filters/manager/DimensionFilter.svelte | 641 ++++++++++++++++++ .../filters/manager/ExpressionFilters.svelte | 102 +++ .../dimension-filter-manager.svelte.ts | 156 +++++ .../expression-filter-manager.svelte.ts | 136 ++++ .../dashboards/filters/manager/types.ts | 6 + .../state-managers/actions/filters.ts | 10 + .../loaders/DashboardStateSync.ts | 13 + .../dashboards/workspace/Dashboard.svelte | 4 + 8 files changed, 1068 insertions(+) create mode 100644 web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte create mode 100644 web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte create mode 100644 web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts create mode 100644 web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts create mode 100644 web-common/src/features/dashboards/filters/manager/types.ts diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte new file mode 100644 index 000000000000..0058b62fb65e --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte @@ -0,0 +1,641 @@ + + + { + if (e.key === "Enter") { + await onApply(); + } + }} +/> + + + + {#snippet child({ props })} + + filterData.clear()} + removable={!readOnly && !curPinned && !filterData.pinned} + {readOnly} + removeTooltipText="remove {filterData.selectedValues + .length} value{filterData.selectedValues.length !== 1 ? 's' : ''}" + > + + +
+ + + {filterData.name} + {filterData.pinned + ? "required dimension" + : "dimension"} + + {#if missingRequired} + This filter is required. Select a value to load the dashboard. + {:else} + Click to edit the filters in this dimension + {/if} + +
+
+ {/snippet} +
+ + + +
+ {#if filterData.editing} +
+ {filterData.label} + +
+ { + curRequired = !curRequired; + }} + /> + { + curPinned = !curPinned; + }} + /> +
+
+ {/if} +
+ + +
+ {#if showExtraInfo} +
+ {#if curMode !== DimensionFilterMode.Select} + + {searchResultCountText} + + {:else} +
+ {/if} +
+ {/if} +
+ + {#if showExtraInfo} + + {/if} + +
+ {#if isFetching} +
+ +
+ {:else if error} +
error
+ {:else if inListTooLong} +
+ List is too long. Please remove some values. +
+ {:else if correctedSearchResults} + + + {#if curMode === DimensionFilterMode.Select && !curSearchText} + {#each checkedItems as name (name)} + {@const selected = effectiveSelectedValues.includes(name)} + {@const label = name ?? "null"} + + handleItemClick(name)} + > + + {#if label.length > 240} + {label.slice(0, 240)}... + {:else} + {label} + {/if} + + + {/each} + {/if} + + + {#if curMode === DimensionFilterMode.Select && !curSearchText && checkedItems.length > 0 && uncheckedItems.length > 0} + + {/if} + + + {#each uncheckedItems as name (name)} + {@const selected = effectiveSelectedValues.includes(name)} + {@const label = name ?? "null"} + + handleItemClick(name)} + > + + {#if label.length > 240} + {label.slice(0, 240)}... + {:else} + {label} + {/if} + + + {/each} + + + {#if uncheckedItems.length === 0 && (curMode !== DimensionFilterMode.Select || checkedItems.length === 0)} +
+ no results +
+ {/if} +
+ {/if} +
+ + +
+
diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte new file mode 100644 index 000000000000..3189b47ae253 --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte @@ -0,0 +1,102 @@ + + +
+
+ {#each expressionFilterManager.dimensionFilterManagers as dimensionFilterManager (dimensionFilterManager.name)} + + {/each} + + + expressionFilterManager.addTemporaryDimensionFilter( + name, + dimensionIdMap, + metricsViewName, + )} + /> + + {#if hasFilters} + + {/if} +
+
diff --git a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts new file mode 100644 index 000000000000..aefaa5a25e8d --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts @@ -0,0 +1,156 @@ +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 { + MetricsViewSpecDimension, + V1Expression, +} from "@rilldata/web-common/runtime-client"; +import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts"; +import { + createInExpression, + createLikeExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; + +export type DimensionFilterManagerInit = { + mode: DimensionFilterMode; + selectedValues?: string[]; + inputText?: string; + exclude?: boolean; + pinned?: boolean; + required?: boolean; +}; + +export class DimensionFilterManager { + public expr: V1Expression | undefined = $state(undefined); + + public mode: DimensionFilterMode; + public selectedValues: string[]; + public inputText: string; + public exclude: boolean; + public pinned: boolean; + public required: boolean; + + public constructor( + public readonly name: string, + public readonly label: string, + public readonly dimensions: Map, + public readonly editing: boolean, // TODO: maybe separate editing, pinned & required? + init: DimensionFilterManagerInit, + ) { + this.mode = $state(init.mode); + this.selectedValues = $state(init.selectedValues ?? []); + this.inputText = $state(init.inputText ?? ""); + this.exclude = $state(init.exclude ?? false); + this.pinned = $state(init.pinned ?? false); + this.required = $state(init.required ?? false); + this.buildExpression(); + } + + public toggleMultipleValues( + dimensionValues: string[], + isExclusiveFilter?: boolean, + exclude?: boolean, + ) { + if (exclude !== undefined) this.exclude = exclude; + + if (this.mode !== DimensionFilterMode.Select) { + this.inputText = ""; + eventBus.emit("notification", { + message: "Converted filter type to Select", + link: { + text: m.common_undo(), + href: page.url.href, + }, + }); + this.mode = DimensionFilterMode.Select; + this.selectedValues = dimensionValues; + return this.buildExpression(); + } + + if (isExclusiveFilter) { + this.selectedValues = dimensionValues; + return this.buildExpression(); + } + + dimensionValues.forEach((v) => { + const removedIndex = this.toggleValue(v, false); + if (removedIndex === -1) return; + + // TODO: decrement pinIndex if the removed value was before the pinned value + }); + + this.buildExpression(); + } + + public toggleValue(dimensionValue: string, isExclusiveFilter: boolean) { + const inIdx = this.selectedValues.findIndex((v) => v === dimensionValue); + let retIdx = inIdx; + + if (inIdx === -1) { + if (isExclusiveFilter) { + this.selectedValues = [dimensionValue]; + retIdx = -1; + } else { + this.selectedValues = [...this.selectedValues, dimensionValue]; + } + } else { + this.selectedValues = this.selectedValues.splice(inIdx, 1); + } + + return retIdx; + } + + public setInList(values: string[], exclude?: boolean) { + if (exclude !== undefined) this.exclude = exclude; + + this.mode = DimensionFilterMode.InList; + this.selectedValues = values; + this.inputText = ""; + this.buildExpression(); + } + + public setContainsText(searchText: string, exclude?: boolean) { + if (exclude !== undefined) this.exclude = exclude; + + this.mode = DimensionFilterMode.Contains; + this.selectedValues = []; + this.inputText = searchText; + this.buildExpression(); + } + + public toggleExclude() { + this.exclude = !this.exclude; + } + + public togglePinned() { + this.pinned = !this.pinned; + } + + public toggleRequired() { + this.required = !this.required; + } + + public clear() { + this.selectedValues = []; + this.inputText = ""; + this.expr = undefined; + this.buildExpression(); + } + + private buildExpression() { + switch (this.mode) { + case DimensionFilterMode.Select: + 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; + } + } +} diff --git a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts new file mode 100644 index 000000000000..06f716ceba27 --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts @@ -0,0 +1,136 @@ +import { + DimensionFilterManager, + type DimensionFilterManagerInit, +} from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; +import type { V1Expression } from "@rilldata/web-admin/client"; +import { + convertExpressionToFilterParam, + convertFilterParamToExpression, +} from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; +import { + createAndExpression, + forEachIdentifier, + getValuesInExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { + type MetricsViewSpecDimension, + V1Operation, +} from "@rilldata/web-common/runtime-client"; +import { getDimensionDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; + +export class FilterManagerManager { + public dimensionFilterManagers: DimensionFilterManager[] = $state([]); + public expr: V1Expression; + public exprParam: string = ""; + + private temporaryFilter: DimensionFilterManager | undefined = + $state(undefined); + + public constructor() { + this.expr = $derived.by(() => { + const exprs = this.dimensionFilterManagers + .filter((dfm) => !!dfm.expr) + .map((d) => d.expr as V1Expression); + return createAndExpression(exprs); + }); + } + + public setExprParam( + exprParam: string, + dimensionIdMap: Map, + metricsViewName: string, + ) { + if (exprParam === this.exprParam) return; + this.exprParam = exprParam; + + const { expr, dimensionsWithInlistFilter } = + convertFilterParamToExpression(exprParam); + // TODO: check complex filter + if (!expr) { + this.dimensionFilterManagers = []; + return; + } + + const addedDimension = new Set(); + const newDimensionFilterManagers: DimensionFilterManager[] = []; + forEachIdentifier(expr, (e, ident) => { + const dim = dimensionIdMap.get(ident); + if (!dim) return; + addedDimension.add(ident); + + const op = e.cond?.op; + let init: DimensionFilterManagerInit | undefined = undefined; + if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { + const isInListMode = dimensionsWithInlistFilter.includes(ident); + init = { + mode: isInListMode + ? DimensionFilterMode.InList + : DimensionFilterMode.Select, + selectedValues: getValuesInExpression(e), + exclude: op === V1Operation.OPERATION_NIN, + inputText: undefined, + }; + } else if ( + op === V1Operation.OPERATION_LIKE || + op === V1Operation.OPERATION_NLIKE + ) { + init = { + mode: DimensionFilterMode.Contains, + selectedValues: [], + inputText: e.cond?.exprs?.[1]?.val?.toString?.() ?? "", + exclude: op === V1Operation.OPERATION_NLIKE, + }; + } + + if (!init) return; + + newDimensionFilterManagers.push( + new DimensionFilterManager( + ident, + getDimensionDisplayName(dim), + new Map([[metricsViewName, dim]]), + false, + init, + ), + ); + }); + + if (this.temporaryFilter) { + if (!addedDimension.has(this.temporaryFilter.name)) { + newDimensionFilterManagers.push(this.temporaryFilter); + } else { + this.temporaryFilter = undefined; + } + } + + this.dimensionFilterManagers = newDimensionFilterManagers; + } + + public addTemporaryDimensionFilter( + name: string, + dimensionIdMap: Map, + metricsViewName: string, + ) { + const dim = dimensionIdMap.get(name); + if (!dim) return; + + this.temporaryFilter = new DimensionFilterManager( + name, + getDimensionDisplayName(dim), + new Map([[metricsViewName, dim]]), + false, + { mode: DimensionFilterMode.Select }, + ); + this.dimensionFilterManagers = [ + ...this.dimensionFilterManagers, + this.temporaryFilter, + ]; + } + + public clear() { + this.dimensionFilterManagers = []; + this.temporaryFilter = undefined; + console.log("clear", [...this.dimensionFilterManagers]); + } +} diff --git a/web-common/src/features/dashboards/filters/manager/types.ts b/web-common/src/features/dashboards/filters/manager/types.ts new file mode 100644 index 000000000000..44398b69b135 --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/types.ts @@ -0,0 +1,6 @@ +import type { V1Expression } from "@rilldata/web-common/runtime-client"; + +export type RawParsedFilter = { + expr: V1Expression | undefined; + dimensionsWithInlistFilter: string[]; +}; 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..760b14553c51 100644 --- a/web-common/src/features/dashboards/state-managers/actions/filters.ts +++ b/web-common/src/features/dashboards/state-managers/actions/filters.ts @@ -1,5 +1,6 @@ import type { DashboardMutables } from "@rilldata/web-common/features/dashboards/state-managers/actions/types"; import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import type { V1Expression } from "@rilldata/web-common/runtime-client"; export function clearAllFilters({ dashboard }: DashboardMutables) { const hasFilters = @@ -23,6 +24,13 @@ export function setTemporaryFilterName( dashboard.temporaryFilterName = name; } +export function setFilter( + { dashboard }: DashboardMutables, + expr: V1Expression, +) { + dashboard.whereFilter = expr; +} + export const filterActions = { /** * Clears all filters and resets related fields @@ -30,4 +38,6 @@ export const filterActions = { clearAllFilters, setTemporaryFilterName, + + setFilter, }; 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..7033c60e9d11 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); @@ -185,6 +186,7 @@ export class DashboardStateSync { ); } + // log("INIT", redirectUrl); // 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; @@ -294,6 +296,7 @@ export class DashboardStateSync { this.updating = false; } + // log("URL", redirectUrl); // 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 +352,7 @@ export class DashboardStateSync { ); } + // log("GOTO", newUrl); // 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 +366,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/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index cbac3e2f4682..9c48ab4c6094 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -22,6 +22,7 @@ import MeasuresContainer from "../big-number/MeasuresContainer.svelte"; import DimensionDisplay from "../dimension-table/DimensionDisplay.svelte"; import Filters from "../filters/Filters.svelte"; + import ExpressionFilters from "../filters/manager/ExpressionFilters.svelte"; import { selectedMockUserStore } from "../granular-access-policies/stores"; import LeaderboardDisplay from "../leaderboard/LeaderboardDisplay.svelte"; import RowsViewerAccordion from "../rows-viewer/RowsViewerAccordion.svelte"; @@ -165,6 +166,9 @@
{:else} {#key exploreName} +
+ +
From 102e28b82e6571733af56356c81654e7563d3892 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Thu, 23 Jul 2026 12:18:16 +0530 Subject: [PATCH 02/21] Simplify proxy logic --- .../DimensionFilterFooter.svelte | 2 +- .../filters/manager/DimensionFilter.svelte | 430 ++++++------------ .../filters/manager/ExpressionFilters.svelte | 26 +- .../dimension-filter-manager.svelte.ts | 140 +++--- .../expression-filter-manager.svelte.ts | 47 +- .../filters/manager/queries.svelte.ts | 172 +++++++ 6 files changed, 416 insertions(+), 401 deletions(-) create mode 100644 web-common/src/features/dashboards/filters/manager/queries.svelte.ts diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte index bd6e59d9ffd7..1b82318efb09 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte @@ -9,7 +9,7 @@ export let excludeMode: boolean; export let allSelected: boolean; export let disableApplyButton: boolean; - export let onToggleExcludeMode: (checked: boolean) => void; + export let onToggleExcludeMode: () => void; export let onToggleSelectAll: () => void; export let onApply: () => void; diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte index 0058b62fb65e..60824566723c 100644 --- a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte @@ -21,18 +21,18 @@ import DimensionFilterChipBody from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilterChipBody.svelte"; import DimensionFilterFooter from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte"; import DimensionFilterModeSelector from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilterModeSelector.svelte"; - import type { V1Expression } from "@rilldata/web-common/runtime-client"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; - import { - useAllSearchResultsCount, - useDimensionSearch, - } from "web-common/src/features/dashboards/filters/dimension-filters/dimension-filter-values"; import PinButton from "../PinButton.svelte"; import RequiredButton from "../RequiredButton.svelte"; import { DimensionFilterManager } from "./dimension-filter-manager.svelte.ts"; + import { + getAllSearchResultsCount, + getDimensionSearchQuery, + } from "@rilldata/web-common/features/dashboards/filters/manager/queries.svelte.ts"; + import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; - export let filterData: DimensionFilterManager; - export let expressionMap: Map; + export let manager: ExpressionFilterManager; + export let dimensionManager: DimensionFilterManager; export let openOnMount: boolean = true; export let readOnly: boolean = false; export let timeStart: string | undefined; @@ -45,51 +45,22 @@ values: string[], ) => boolean = () => false; + let proxyDimensionManager = $derived(dimensionManager.clone()); + let open = - openOnMount && !filterData.selectedValues?.length && !filterData.inputText; - let curMode = filterData.mode; - let curSearchText = filterData.inputText ?? ""; - let curExcludeMode = filterData.exclude === true; + openOnMount && + !dimensionManager.selectedValues?.length && + !dimensionManager.inputText; + let curSearchText = dimensionManager.inputText ?? ""; let inListTooLong = false; - let selectedValuesProxy: string[] = filterData.selectedValues ?? []; - let searchedBulkValues: string[] = - filterData.mode === DimensionFilterMode.InList - ? (filterData.selectedValues ?? []) - : []; - let curPinned = filterData.pinned; - let curRequired = filterData.required; - let excludeModeDirty = false; const client = useRuntimeClient(); - $: missingRequired = Boolean(filterData.pinned && !filterData.expr); - - $: if ( - !open && - excludeModeDirty && - (filterData.exclude === true) === curExcludeMode - ) { - excludeModeDirty = false; - } - - $: if ( - !open && - (filterData.mode !== curMode || - (!excludeModeDirty && (filterData.exclude === true) !== curExcludeMode)) - ) { - resyncFilterData(); - } - - // Sync proxy when selectedValues changes (for Select mode) - $: if (!open && filterData.mode === DimensionFilterMode.Select) { - selectedValuesProxy = [...filterData.selectedValues]; - } - - $: metricsViewNames = Array.from(filterData.dimensions.keys()); - - $: excludeMode = filterData.exclude === true; + $: missingRequired = Boolean( + dimensionManager.pinned && !dimensionManager.expr, + ); - $: sanitisedSearchText = filterData.inputText + $: sanitisedSearchText = dimensionManager.inputText ?.replace(/^%/, "") .replace(/%$/, ""); @@ -97,27 +68,24 @@ $: enableSearchQuery = Boolean(timeControlsReady && open) && - (curMode === DimensionFilterMode.Select || - (curMode === DimensionFilterMode.Contains && curSearchText.length > 0) || - (curMode === DimensionFilterMode.InList && - searchedBulkValues.length > 0)); + (proxyDimensionManager.mode === DimensionFilterMode.Select || + (proxyDimensionManager.mode === DimensionFilterMode.Contains && + curSearchText.length > 0) || + (proxyDimensionManager.mode === DimensionFilterMode.InList && + proxyDimensionManager.selectedValues.length > 0)); - $: searchResultsQuery = useDimensionSearch( + $: searchResultsQuery = getDimensionSearchQuery( client, - metricsViewNames, - filterData.name, + manager, + dimensionManager, { - mode: curMode, - values: - curMode === DimensionFilterMode.Select - ? filterData.selectedValues - : searchedBulkValues, + mode: proxyDimensionManager.mode, + values: proxyDimensionManager.selectedValues, searchText: curSearchText, timeStart, timeEnd, timeDimension, enabled: enableSearchQuery, - metricsViewWheres: expressionMap, }, ); $: ({ @@ -129,24 +97,23 @@ $: enableSearchCountQuery = Boolean(timeControlsReady) && - ((curMode === DimensionFilterMode.Contains && curSearchText.length > 0) || - (curMode === DimensionFilterMode.InList && - searchedBulkValues.length > 0)); + ((proxyDimensionManager.mode === DimensionFilterMode.Contains && + curSearchText.length > 0) || + (proxyDimensionManager.mode === DimensionFilterMode.InList && + proxyDimensionManager.selectedValues.length > 0)); - $: allSearchResultsCountQuery = useAllSearchResultsCount( + $: allSearchResultsCountQuery = getAllSearchResultsCount( client, - metricsViewNames, - filterData.name, + manager, + dimensionManager, { - mode: curMode, - values: searchedBulkValues, + mode: proxyDimensionManager.mode, + values: proxyDimensionManager.selectedValues, searchText: curSearchText, timeStart, timeEnd, timeDimension, enabled: enableSearchCountQuery, - - metricsViewWheres: expressionMap, }, ); $: ({ @@ -155,32 +122,32 @@ isFetching: isFetchingFromAllSearchResultsCount, } = $allSearchResultsCountQuery); $: searchResultCountText = enableSearchCountQuery - ? curMode === DimensionFilterMode.Contains + ? proxyDimensionManager.mode === DimensionFilterMode.Contains ? `${allSearchResultsCount} results` - : `${allSearchResultsCount} of ${searchedBulkValues.length} matched` + : `${allSearchResultsCount} of ${proxyDimensionManager.selectedValues.length} matched` : "0 results"; - $: searchPlaceholder = getSearchPlaceholder(curMode); + $: searchPlaceholder = getSearchPlaceholder(proxyDimensionManager.mode); $: error = errorFromSearchResults ?? errorFromAllSearchResultsCount; $: isFetching = isFetchingFromSearchResults ?? isFetchingFromAllSearchResultsCount; - $: showExtraInfo = curMode !== DimensionFilterMode.Select; // || curSearchText.length > 0; (Add once we have docs) + $: showExtraInfo = proxyDimensionManager.mode !== DimensionFilterMode.Select; // || curSearchText.length > 0; (Add once we have docs) $: allSelected = Boolean( effectiveSelectedValues.length && correctedSearchResults?.length === effectiveSelectedValues.length, ); $: effectiveSelectedValues = getEffectiveSelectedValues( - curMode, - selectedValuesProxy, + proxyDimensionManager.mode, + proxyDimensionManager.selectedValues, correctedSearchResults ?? [], - filterData.selectedValues, + dimensionManager.selectedValues, ); $: disableApplyButton = shouldDisableApplyButton( - curMode, + proxyDimensionManager.mode, enableSearchCountQuery, inListTooLong, ); @@ -188,225 +155,107 @@ // Split results into checked and unchecked for better UX (like SelectionDropdown) // Use actual selectedValues (not proxy) so items only sort after dropdown closes $: ({ checkedItems, uncheckedItems } = getItemLists( - curMode, + proxyDimensionManager.mode, correctedSearchResults ?? [], - filterData.selectedValues, + dimensionManager.selectedValues, curSearchText, )); - /** - * Reset filter settings based on params to the component. - */ - function resetFilterSettings( - mode: DimensionFilterMode, - sanitisedSearchText: string | undefined, - ) { - curExcludeMode = excludeMode; - switch (mode) { - case DimensionFilterMode.Select: - curMode = DimensionFilterMode.Select; - curSearchText = ""; - selectedValuesProxy = [...filterData.selectedValues]; - break; - - case DimensionFilterMode.InList: - curMode = DimensionFilterMode.InList; - curSearchText = mergeDimensionSearchValues(filterData.selectedValues); - searchedBulkValues = filterData.selectedValues; // Ensure searchedBulkValues includes existing selections - break; - - case DimensionFilterMode.Contains: - curMode = DimensionFilterMode.Contains; - curSearchText = sanitisedSearchText ?? ""; - break; - } - } - function checkSearchText(inputText: string) { inListTooLong = false; // Only InList mode parses bulk values. Other modes treat input as search text. - if (curMode !== DimensionFilterMode.InList) return; + if (proxyDimensionManager.mode !== DimensionFilterMode.InList) return; const values = splitDimensionSearchText(inputText); if (values.length <= 1) { - searchedBulkValues = inputText === "" ? [] : values; + proxyDimensionManager.selectedValues = inputText === "" ? [] : values; return; } // Include both existing selected values and new search values so the // below-fold query can find existing selected values that might not be // in the top 250. - searchedBulkValues = [ - ...new Set([...filterData.selectedValues, ...values]), + proxyDimensionManager.selectedValues = [ + ...new Set([...proxyDimensionManager.selectedValues, ...values]), ]; - inListTooLong = isUrlTooLongAfterInListFilter(searchedBulkValues); + inListTooLong = isUrlTooLongAfterInListFilter( + proxyDimensionManager.selectedValues, + ); } function handleModeChange(newMode: DimensionFilterMode) { curSearchText = ""; - if (newMode !== DimensionFilterMode.InList) { - searchedBulkValues = []; - // Reset proxy when switching to/from Select mode - if (newMode === DimensionFilterMode.Select) { - curExcludeMode = excludeMode; - selectedValuesProxy = [...filterData.selectedValues]; - } - } else { - checkSearchText(curSearchText); + switch (newMode) { + case DimensionFilterMode.Select: + proxyDimensionManager.setSelectedValues( + [...proxyDimensionManager.selectedValues], + proxyDimensionManager.exclude, + ); + break; + + case DimensionFilterMode.InList: + proxyDimensionManager.setInList( + [...proxyDimensionManager.selectedValues], + proxyDimensionManager.exclude, + ); + break; + + case DimensionFilterMode.Contains: + proxyDimensionManager.setContainsText( + curSearchText, + proxyDimensionManager.exclude, + ); + break; } + checkSearchText(curSearchText); } - async function handleOpenChange(open: boolean) { + function handleOpenChange(open: boolean) { if (open) { curSearchText = - filterData.mode === DimensionFilterMode.InList - ? mergeDimensionSearchValues(filterData.selectedValues) + dimensionManager.mode === DimensionFilterMode.InList + ? mergeDimensionSearchValues(dimensionManager.selectedValues) : (sanitisedSearchText ?? ""); } else { - if (filterData.pinned !== curPinned) { - filterData.togglePinned(); - } - if (filterData.pinned !== curRequired) { - filterData.toggleRequired(); - } - // Apply proxy changes for Select mode when dropdown closes - if (curMode === DimensionFilterMode.Select) { - await applySelectModeChanges(); - // Don't reset immediately for Select mode - let props update first - return; - } - - if (filterData.selectedValues.length === 0 && !filterData.inputText) { - // filter was cleared. so remove the filter - filterData.clear(); // TODO: await + if (proxyDimensionManager.mode === DimensionFilterMode.Select) { + dimensionManager.expr = proxyDimensionManager.expr; } else { - // reset the settings on unmount (but not for Select mode) - resetFilterSettings(filterData.mode, sanitisedSearchText); + proxyDimensionManager = dimensionManager.clone(); } } } - function handleToggleExcludeMode(checked: boolean) { - curExcludeMode = checked; - excludeModeDirty = true; + function handleToggleExcludeMode() { + proxyDimensionManager.toggleExclude(); } function onToggleSelectAll() { - if (curMode === DimensionFilterMode.Select) { - // Update proxy for select all/deselect all - if (allSelected) { - selectedValuesProxy = selectedValuesProxy.filter( - (v) => !correctedSearchResults?.includes(v), - ); - } else { - const newValues = - correctedSearchResults?.filter( - (v) => !selectedValuesProxy.includes(v), - ) ?? []; - selectedValuesProxy = [...selectedValuesProxy, ...newValues]; - } - } else { - correctedSearchResults?.forEach((dimensionValue) => { - if (!allSelected && effectiveSelectedValues.includes(dimensionValue)) - return; - - filterData.toggleMultipleValues([dimensionValue]); - }); - } + proxyDimensionManager.setSelectedValues( + [ + ...proxyDimensionManager.selectedValues, + ...(correctedSearchResults ?? []), + ], + proxyDimensionManager.exclude, + ); } - async function onApply(close = true) { + function onApply(close = true) { if (disableApplyButton) return; - const newExclude = - curExcludeMode !== excludeMode ? curExcludeMode : undefined; - switch (curMode) { - case DimensionFilterMode.Select: - // Apply proxy changes for Select mode - await applySelectModeChanges(); - if (close) open = false; - break; - case DimensionFilterMode.InList: - if (searchedBulkValues.length === 0) return; - filterData.setInList(searchedBulkValues, newExclude); // TODO: await - if (close) open = false; - break; - case DimensionFilterMode.Contains: - if (curSearchText.length === 0) return; - filterData.setContainsText(curSearchText, newExclude); // TODO: await - if (close) open = false; - break; - } + dimensionManager.expr = proxyDimensionManager.expr; + if (close) open = false; } - async function applySelectModeChanges() { - // Find values that were added or removed - const currentValues = new Set(filterData.selectedValues); - const proxyValues = new Set(selectedValuesProxy); - const changedValues = [...currentValues, ...proxyValues].filter((value) => { - const wasSelected = currentValues.has(value); - const isSelected = proxyValues.has(value); - - return wasSelected !== isSelected; - }); - const shouldToggleExcludeMode = curExcludeMode !== excludeMode; - const shouldCommitSelectMode = - filterData.mode !== DimensionFilterMode.Select && currentValues.size > 0; - - if (!currentValues.size && !proxyValues.size) { - console.log("no change"); - // No changes - return; - } - - // Existing filters still need to be negated. New filters get the operator - // from curExcludeMode when values are committed below. - if (shouldToggleExcludeMode && currentValues.size > 0) { - console.log("toggleExclude"); - filterData.toggleExclude(); // TODO: await - } - - if (changedValues.length || shouldCommitSelectMode) { - console.log("toggleMultipleValues"); - filterData.toggleMultipleValues(changedValues, undefined, curExcludeMode); // TODO: await - } - } - - async function handleItemClick(value: string) { - if (curMode === DimensionFilterMode.Select) { - // Update proxy instead of calling onSelect immediately - if (selectedValuesProxy.includes(value)) { - selectedValuesProxy = selectedValuesProxy.filter((v) => v !== value); - } else { - selectedValuesProxy = [...selectedValuesProxy, value]; - } - } else { - filterData.toggleMultipleValues([value]); // TODO: await - } - } - - function resyncFilterData() { - curMode = filterData.mode; - curSearchText = filterData.inputText ?? ""; - curExcludeMode = filterData.exclude === true; - excludeModeDirty = false; - selectedValuesProxy = filterData.selectedValues ?? []; - searchedBulkValues = - filterData.mode === DimensionFilterMode.InList - ? filterData.selectedValues - : []; - curPinned = filterData.pinned; - curRequired = filterData.required; + function handleItemClick(value: string) { + proxyDimensionManager.toggleValue(value, false); } { - if (e.key === "Enter") { - await onApply(); - } + if (e.key === "Enter") onApply(); }} /> @@ -423,31 +272,36 @@ filterData.clear()} - removable={!readOnly && !curPinned && !filterData.pinned} + onRemove={() => dimensionManager.clear()} + removable={!readOnly && + !proxyDimensionManager.pinned && + !dimensionManager.pinned} {readOnly} - removeTooltipText="remove {filterData.selectedValues - .length} value{filterData.selectedValues.length !== 1 ? 's' : ''}" + removeTooltipText="remove {dimensionManager.selectedValues + .length} value{dimensionManager.selectedValues.length !== 1 + ? 's' + : ''}" > @@ -455,9 +309,11 @@
- {filterData.name} + {dimensionManager.name} {filterData.pinned + >{dimensionManager.pinned ? "required dimension" : "dimension"} @@ -481,37 +337,33 @@ class="flex flex-col max-h-96 w-[400px] overflow-hidden p-0" >
- {#if filterData.editing} + {#if dimensionManager.editing}
- {filterData.label} + {dimensionManager.label}
{ - curRequired = !curRequired; - }} + required={proxyDimensionManager.required} + onToggleRequired={() => proxyDimensionManager.toggleRequired()} /> { - curPinned = !curPinned; - }} + pinned={proxyDimensionManager.pinned} + onTogglePin={() => proxyDimensionManager.togglePinned()} />
{/if}
{#if showExtraInfo}
- {#if curMode !== DimensionFilterMode.Select} + {#if proxyDimensionManager.mode !== DimensionFilterMode.Select} {searchResultCountText} @@ -557,10 +409,10 @@ {:else if correctedSearchResults} - {#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"} @@ -570,7 +422,7 @@ closeOnSelect={false} class="text-xs cursor-pointer" checked={selected} - showXForSelected={curExcludeMode} + showXForSelected={proxyDimensionManager.exclude} onclick={() => handleItemClick(name)} > @@ -585,7 +437,7 @@ {/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} @@ -595,17 +447,19 @@ {@const label = name ?? "null"} handleItemClick(name)} > @@ -619,7 +473,7 @@ {/each} - {#if uncheckedItems.length === 0 && (curMode !== DimensionFilterMode.Select || checkedItems.length === 0)} + {#if uncheckedItems.length === 0 && (proxyDimensionManager.mode !== DimensionFilterMode.Select || checkedItems.length === 0)}
no results
@@ -629,8 +483,8 @@
- expressionFilterManager.setExprParam( + manager.setExprParam( filterUrlStore.value ?? "", dimensionIdMap, metricsViewName, ), ); - let hasFilters = $derived( - expressionFilterManager.dimensionFilterManagers.length > 0, - ); - - $effect(() => setFilter(expressionFilterManager.expr)); + let hasFilters = $derived(manager.dimensionFilterManagers.length > 0); - let expressionMap = $derived( - new Map([[metricsViewName, expressionFilterManager.expr]]), - ); + $effect(() => setFilter(manager.expr)); let { selectedTimeDimension } = $derived($dashboardStore); const timeControlsStore = useTimeControlStore(StateManagers); @@ -68,10 +62,10 @@
- {#each expressionFilterManager.dimensionFilterManagers as dimensionFilterManager (dimensionFilterManager.name)} + {#each manager.dimensionFilterManagers as dimensionManager (dimensionManager.name)} - expressionFilterManager.addTemporaryDimensionFilter( + manager.addTemporaryDimensionFilter( name, dimensionIdMap, metricsViewName, @@ -94,7 +88,7 @@ {#if hasFilters} - {/if} diff --git a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts index aefaa5a25e8d..c16dfb2501f0 100644 --- a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts @@ -1,23 +1,24 @@ 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 { - MetricsViewSpecDimension, - V1Expression, +import { + type MetricsViewSpecDimension, + type V1Expression, + V1Operation, } from "@rilldata/web-common/runtime-client"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts"; import { + copyFilterExpression, createInExpression, createLikeExpression, + getValuesInExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; export type DimensionFilterManagerInit = { mode: DimensionFilterMode; - selectedValues?: string[]; - inputText?: string; - exclude?: boolean; - pinned?: boolean; - required?: boolean; + selectedValues: string[]; + inputText: string; + exclude: boolean; }; export class DimensionFilterManager { @@ -30,96 +31,100 @@ export class DimensionFilterManager { public pinned: boolean; public required: boolean; + private oldMode: DimensionFilterMode; + public constructor( public readonly name: string, public readonly label: string, public readonly dimensions: Map, public readonly editing: boolean, // TODO: maybe separate editing, pinned & required? - init: DimensionFilterManagerInit, + initExpr: V1Expression = createInExpression(name, []), + isInList: boolean = false, ) { - this.mode = $state(init.mode); - this.selectedValues = $state(init.selectedValues ?? []); - this.inputText = $state(init.inputText ?? ""); - this.exclude = $state(init.exclude ?? false); - this.pinned = $state(init.pinned ?? false); - this.required = $state(init.required ?? false); - this.buildExpression(); - } - - public toggleMultipleValues( - dimensionValues: string[], - isExclusiveFilter?: boolean, - exclude?: boolean, - ) { - if (exclude !== undefined) this.exclude = exclude; - - if (this.mode !== DimensionFilterMode.Select) { - this.inputText = ""; - eventBus.emit("notification", { - message: "Converted filter type to Select", - link: { - text: m.common_undo(), - href: page.url.href, - }, - }); - this.mode = DimensionFilterMode.Select; - this.selectedValues = dimensionValues; - return this.buildExpression(); - } - - if (isExclusiveFilter) { - this.selectedValues = dimensionValues; - return this.buildExpression(); + let initMode: DimensionFilterMode = DimensionFilterMode.Select; + let initSelectedValues: string[] = []; + let initInputText: string = ""; + let initExclude: boolean = false; + + const op = initExpr.cond?.op; + if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { + initMode = isInList + ? DimensionFilterMode.InList + : DimensionFilterMode.Select; + initSelectedValues = getValuesInExpression(initExpr); + initExclude = op === V1Operation.OPERATION_NIN; + } else if ( + op === V1Operation.OPERATION_LIKE || + op === V1Operation.OPERATION_NLIKE + ) { + initMode = DimensionFilterMode.Contains; + initInputText = initExpr.cond?.exprs?.[1]?.val?.toString?.() ?? ""; + initExclude = op === V1Operation.OPERATION_NLIKE; } - dimensionValues.forEach((v) => { - const removedIndex = this.toggleValue(v, false); - if (removedIndex === -1) return; + this.mode = $state(initMode); + this.oldMode = initMode; + this.selectedValues = $state(initSelectedValues); + this.inputText = $state(initInputText); + this.exclude = $state(initExclude); + this.pinned = $state(false); + this.required = $state(false); + this.commit(); + } - // TODO: decrement pinIndex if the removed value was before the pinned value - }); + public clone() { + return new DimensionFilterManager( + this.name, + this.label, + this.dimensions, + this.editing, + this.expr, + this.mode === DimensionFilterMode.InList, + ); + } - this.buildExpression(); + 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); - let retIdx = inIdx; if (inIdx === -1) { if (isExclusiveFilter) { this.selectedValues = [dimensionValue]; - retIdx = -1; } else { this.selectedValues = [...this.selectedValues, dimensionValue]; } } else { this.selectedValues = this.selectedValues.splice(inIdx, 1); } - - return retIdx; + this.commit(); } - public setInList(values: string[], exclude?: boolean) { - if (exclude !== undefined) this.exclude = exclude; - + public setInList(values: string[], exclude: boolean) { this.mode = DimensionFilterMode.InList; this.selectedValues = values; this.inputText = ""; - this.buildExpression(); + this.exclude = exclude; + this.commit(); } - public setContainsText(searchText: string, exclude?: boolean) { - if (exclude !== undefined) this.exclude = exclude; - + public setContainsText(searchText: string, exclude: boolean) { this.mode = DimensionFilterMode.Contains; this.selectedValues = []; this.inputText = searchText; - this.buildExpression(); + this.exclude = exclude; + this.commit(); } public toggleExclude() { this.exclude = !this.exclude; + this.commit(); } public togglePinned() { @@ -134,12 +139,22 @@ export class DimensionFilterManager { this.selectedValues = []; this.inputText = ""; this.expr = undefined; - this.buildExpression(); + this.commit(); } - private buildExpression() { + public commit() { switch (this.mode) { case DimensionFilterMode.Select: + if (this.oldMode !== DimensionFilterMode.Select) { + eventBus.emit("notification", { + message: "Converted filter type 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) @@ -152,5 +167,6 @@ export class DimensionFilterManager { : undefined; break; } + this.oldMode = this.mode; } } diff --git a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts index 06f716ceba27..d0b4fc020e70 100644 --- a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts @@ -3,12 +3,10 @@ import { type DimensionFilterManagerInit, } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; import type { V1Expression } from "@rilldata/web-admin/client"; -import { - convertExpressionToFilterParam, - convertFilterParamToExpression, -} from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; +import { convertFilterParamToExpression } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; import { createAndExpression, + createInExpression, forEachIdentifier, getValuesInExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; @@ -19,7 +17,7 @@ import { import { getDimensionDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; -export class FilterManagerManager { +export class ExpressionFilterManager { public dimensionFilterManagers: DimensionFilterManager[] = $state([]); public expr: V1Expression; public exprParam: string = ""; @@ -59,39 +57,15 @@ export class FilterManagerManager { if (!dim) return; addedDimension.add(ident); - const op = e.cond?.op; - let init: DimensionFilterManagerInit | undefined = undefined; - if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { - const isInListMode = dimensionsWithInlistFilter.includes(ident); - init = { - mode: isInListMode - ? DimensionFilterMode.InList - : DimensionFilterMode.Select, - selectedValues: getValuesInExpression(e), - exclude: op === V1Operation.OPERATION_NIN, - inputText: undefined, - }; - } else if ( - op === V1Operation.OPERATION_LIKE || - op === V1Operation.OPERATION_NLIKE - ) { - init = { - mode: DimensionFilterMode.Contains, - selectedValues: [], - inputText: e.cond?.exprs?.[1]?.val?.toString?.() ?? "", - exclude: op === V1Operation.OPERATION_NLIKE, - }; - } - - if (!init) return; - + const isInListMode = dimensionsWithInlistFilter.includes(ident); newDimensionFilterManagers.push( new DimensionFilterManager( ident, getDimensionDisplayName(dim), new Map([[metricsViewName, dim]]), false, - init, + e, + isInListMode, ), ); }); @@ -120,7 +94,6 @@ export class FilterManagerManager { getDimensionDisplayName(dim), new Map([[metricsViewName, dim]]), false, - { mode: DimensionFilterMode.Select }, ); this.dimensionFilterManagers = [ ...this.dimensionFilterManagers, @@ -131,6 +104,12 @@ export class FilterManagerManager { public clear() { this.dimensionFilterManagers = []; this.temporaryFilter = undefined; - console.log("clear", [...this.dimensionFilterManagers]); + } + + public getOtherDimensionsFilter(name: string) { + const exprs = this.dimensionFilterManagers + .filter((dfm) => !!dfm.expr && dfm.name !== name) + .map((d) => d.expr as V1Expression); + return exprs.length === 0 ? undefined : createAndExpression(exprs); } } diff --git a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts new file mode 100644 index 000000000000..9c4762f4e68a --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts @@ -0,0 +1,172 @@ +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; +import type { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; +import { createQuery } from "@tanstack/svelte-query"; +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"; + +type DimensionSearchArgs = { + mode: DimensionFilterMode; + searchText: string; + values: string[]; + timeStart?: string; + timeEnd?: string; + timeDimension?: string; + enabled?: boolean; +}; + +export function getDimensionSearchQuery( + client: RuntimeClient, + manager: ExpressionFilterManager, + dimensionManager: DimensionFilterManager, + { + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + }: DimensionSearchArgs, +) { + const dimensionName = dimensionManager.name; + const mvName = [...dimensionManager.dimensions.keys()][0] ?? ""; + + const otherDimsFilter = $derived.by(() => { + manager.expr; // Force rederivation when expr changes. + return manager.getOtherDimensionsFilter(dimensionManager.name); + }); + const where = getFilterForSearchArgs(dimensionManager.name, { + mode, + searchText, + values, + additionalFilter: otherDimsFilter, + }); + + const optionsStore = $derived( + getQueryServiceMetricsViewAggregationQueryOptions( + client, + { + metricsView: mvName, + dimensions: [{ name: dimensionName }], + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + limit: "250", + offset: "0", + sort: [{ name: dimensionName }], + where, + }, + { + query: { + select: (resp) => + resp.data?.map((d) => d[dimensionName] as string) ?? [], + enabled, + }, + }, + ), + ); + return createQuery(optionsStore); +} + +export function getAllSearchResultsCount( + client: RuntimeClient, + manager: ExpressionFilterManager, + dimensionManager: DimensionFilterManager, + { + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + }: DimensionSearchArgs, +) { + const dimensionName = dimensionManager.name; + const mvName = [...dimensionManager.dimensions.keys()][0] ?? ""; + const countMeasureName = dimensionName + "__distinct_count"; + + const otherDimsFilter = $derived.by(() => { + manager.expr; // Force rederivation when expr changes. + return manager.getOtherDimensionsFilter(dimensionManager.name); + }); + const where = getFilterForSearchArgs(dimensionManager.name, { + mode, + searchText, + values, + additionalFilter: otherDimsFilter, + }); + const optionsStore = $derived( + getQueryServiceMetricsViewAggregationQueryOptions( + client, + { + metricsView: mvName, + measures: [ + { + name: countMeasureName, + builtinMeasure: V1BuiltinMeasure.BUILTIN_MEASURE_COUNT_DISTINCT, + builtinMeasureArgs: [dimensionName], + }, + ], + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + where, + }, + { + query: { + enabled, + select: (resp) => { + if (!resp.data?.length) return 0; + return resp.data[0][countMeasureName] as number; + }, + }, + }, + ), + ); + return createQuery(optionsStore); +} + +/** + * 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; +} From 8307b26b02f608f606440500bdbb11135d63ff4f Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Thu, 23 Jul 2026 19:01:36 +0530 Subject: [PATCH 03/21] Add measure filters --- .../filters/manager/DimensionFilter.svelte | 204 ++++++++++-------- .../filters/manager/ExpressionFilters.svelte | 17 +- .../filters/manager/MeasureFilter.svelte | 147 +++++++++++++ .../dimension-filter-manager.svelte.ts | 9 +- .../expression-filter-manager.svelte.ts | 120 ++++++++--- .../manager/measure-filter-manager.svelte.ts | 90 ++++++++ .../store-utils/url-params-state.svelte.ts | 5 +- 7 files changed, 471 insertions(+), 121 deletions(-) create mode 100644 web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte create mode 100644 web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte index 60824566723c..be51f15bc230 100644 --- a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte @@ -31,54 +31,65 @@ } from "@rilldata/web-common/features/dashboards/filters/manager/queries.svelte.ts"; import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; - export let manager: ExpressionFilterManager; - export let dimensionManager: DimensionFilterManager; - export let openOnMount: boolean = true; - export let readOnly: boolean = false; - export let timeStart: string | undefined; - export let timeEnd: string | undefined; - export let timeDimension: string | undefined = undefined; - export let timeControlsReady: boolean | undefined; - export let smallChip = false; - export let side: "top" | "right" | "bottom" | "left" = "bottom"; - export let isUrlTooLongAfterInListFilter: ( - values: string[], - ) => boolean = () => false; + let { + manager, + dimensionManager, + openOnMount = true, + readOnly = false, + timeStart, + timeEnd, + timeDimension = undefined, + timeControlsReady, + smallChip = false, + side = "bottom", + isUrlTooLongAfterInListFilter = () => false, + }: { + manager: ExpressionFilterManager; + dimensionManager: DimensionFilterManager; + openOnMount?: boolean; + readOnly?: boolean; + timeStart: string | undefined; + timeEnd: string | undefined; + timeDimension: string | undefined; + timeControlsReady: boolean | undefined; + smallChip?: boolean; + side?: "top" | "right" | "bottom" | "left"; + isUrlTooLongAfterInListFilter?: (values: string[]) => boolean; + } = $props(); let proxyDimensionManager = $derived(dimensionManager.clone()); - let open = + let open = $state( + // eslint-disable-next-line svelte/valid-compile openOnMount && - !dimensionManager.selectedValues?.length && - !dimensionManager.inputText; - let curSearchText = dimensionManager.inputText ?? ""; - let inListTooLong = false; + // eslint-disable-next-line svelte/valid-compile + !dimensionManager.selectedValues?.length && + // eslint-disable-next-line svelte/valid-compile + !dimensionManager.inputText, + ); + // eslint-disable-next-line svelte/valid-compile + let curSearchText = $state(dimensionManager.inputText ?? ""); + let inListTooLong = $state(false); const client = useRuntimeClient(); - $: missingRequired = Boolean( - dimensionManager.pinned && !dimensionManager.expr, + let missingRequired = $derived( + Boolean(dimensionManager.pinned && !dimensionManager.expr), ); - $: sanitisedSearchText = dimensionManager.inputText - ?.replace(/^%/, "") - .replace(/%$/, ""); + $effect(() => checkSearchText(curSearchText)); - $: checkSearchText(curSearchText); - - $: enableSearchQuery = + let enableSearchQuery = $derived( Boolean(timeControlsReady && open) && - (proxyDimensionManager.mode === DimensionFilterMode.Select || - (proxyDimensionManager.mode === DimensionFilterMode.Contains && - curSearchText.length > 0) || - (proxyDimensionManager.mode === DimensionFilterMode.InList && - proxyDimensionManager.selectedValues.length > 0)); + (proxyDimensionManager.mode === DimensionFilterMode.Select || + (proxyDimensionManager.mode === DimensionFilterMode.Contains && + curSearchText.length > 0) || + (proxyDimensionManager.mode === DimensionFilterMode.InList && + proxyDimensionManager.selectedValues.length > 0)), + ); - $: searchResultsQuery = getDimensionSearchQuery( - client, - manager, - dimensionManager, - { + let searchResultsQuery = $derived( + getDimensionSearchQuery(client, manager, dimensionManager, { mode: proxyDimensionManager.mode, values: proxyDimensionManager.selectedValues, searchText: curSearchText, @@ -86,27 +97,25 @@ timeEnd, timeDimension, enabled: enableSearchQuery, - }, + }), ); - $: ({ + let { data: searchResults, error: errorFromSearchResults, isFetching: isFetchingFromSearchResults, - } = $searchResultsQuery); - $: correctedSearchResults = enableSearchQuery ? searchResults : []; + } = $derived($searchResultsQuery); + let correctedSearchResults = $derived(enableSearchQuery ? searchResults : []); - $: enableSearchCountQuery = + let enableSearchCountQuery = $derived( Boolean(timeControlsReady) && - ((proxyDimensionManager.mode === DimensionFilterMode.Contains && - curSearchText.length > 0) || - (proxyDimensionManager.mode === DimensionFilterMode.InList && - proxyDimensionManager.selectedValues.length > 0)); + ((proxyDimensionManager.mode === DimensionFilterMode.Contains && + curSearchText.length > 0) || + (proxyDimensionManager.mode === DimensionFilterMode.InList && + proxyDimensionManager.selectedValues.length > 0)), + ); - $: allSearchResultsCountQuery = getAllSearchResultsCount( - client, - manager, - dimensionManager, - { + let allSearchResultsCountQuery = $derived( + getAllSearchResultsCount(client, manager, dimensionManager, { mode: proxyDimensionManager.mode, values: proxyDimensionManager.selectedValues, searchText: curSearchText, @@ -114,52 +123,69 @@ timeEnd, timeDimension, enabled: enableSearchCountQuery, - }, + }), ); - $: ({ + let { data: allSearchResultsCount, error: errorFromAllSearchResultsCount, isFetching: isFetchingFromAllSearchResultsCount, - } = $allSearchResultsCountQuery); - $: searchResultCountText = enableSearchCountQuery - ? proxyDimensionManager.mode === DimensionFilterMode.Contains - ? `${allSearchResultsCount} results` - : `${allSearchResultsCount} of ${proxyDimensionManager.selectedValues.length} matched` - : "0 results"; + } = $derived($allSearchResultsCountQuery); + let searchResultCountText = $derived( + enableSearchCountQuery + ? proxyDimensionManager.mode === DimensionFilterMode.Contains + ? `${allSearchResultsCount} results` + : `${allSearchResultsCount} of ${proxyDimensionManager.selectedValues.length} matched` + : "0 results", + ); - $: searchPlaceholder = getSearchPlaceholder(proxyDimensionManager.mode); + let searchPlaceholder = $derived( + getSearchPlaceholder(proxyDimensionManager.mode), + ); - $: error = errorFromSearchResults ?? errorFromAllSearchResultsCount; - $: isFetching = - isFetchingFromSearchResults ?? isFetchingFromAllSearchResultsCount; + let error = $derived( + errorFromSearchResults ?? errorFromAllSearchResultsCount, + ); + let isFetching = $derived( + isFetchingFromSearchResults ?? isFetchingFromAllSearchResultsCount, + ); - $: showExtraInfo = proxyDimensionManager.mode !== DimensionFilterMode.Select; // || curSearchText.length > 0; (Add once we have docs) + let showExtraInfo = $derived( + proxyDimensionManager.mode !== DimensionFilterMode.Select, + ); // || curSearchText.length > 0; (Add once we have docs) - $: allSelected = Boolean( - effectiveSelectedValues.length && - correctedSearchResults?.length === effectiveSelectedValues.length, + let effectiveSelectedValues = $derived( + getEffectiveSelectedValues( + proxyDimensionManager.mode, + proxyDimensionManager.selectedValues, + correctedSearchResults ?? [], + dimensionManager.selectedValues, + ), ); - $: effectiveSelectedValues = getEffectiveSelectedValues( - proxyDimensionManager.mode, - proxyDimensionManager.selectedValues, - correctedSearchResults ?? [], - dimensionManager.selectedValues, + let allSelected = $derived( + Boolean( + effectiveSelectedValues.length && + correctedSearchResults?.length === effectiveSelectedValues.length, + ), ); - $: disableApplyButton = shouldDisableApplyButton( - proxyDimensionManager.mode, - enableSearchCountQuery, - inListTooLong, + let disableApplyButton = $derived( + shouldDisableApplyButton( + proxyDimensionManager.mode, + enableSearchCountQuery, + inListTooLong, + ), ); // Split results into checked and unchecked for better UX (like SelectionDropdown) // Use actual selectedValues (not proxy) so items only sort after dropdown closes - $: ({ checkedItems, uncheckedItems } = getItemLists( - proxyDimensionManager.mode, - correctedSearchResults ?? [], - dimensionManager.selectedValues, - curSearchText, - )); + let { checkedItems, uncheckedItems } = $derived( + getItemLists( + proxyDimensionManager.mode, + correctedSearchResults ?? [], + dimensionManager.selectedValues, + curSearchText, + ), + ); function checkSearchText(inputText: string) { inListTooLong = false; @@ -217,7 +243,7 @@ curSearchText = dimensionManager.mode === DimensionFilterMode.InList ? mergeDimensionSearchValues(dimensionManager.selectedValues) - : (sanitisedSearchText ?? ""); + : dimensionManager.inputText; } else { // Apply proxy changes for Select mode when dropdown closes if (proxyDimensionManager.mode === DimensionFilterMode.Select) { @@ -417,8 +443,7 @@ {@const selected = effectiveSelectedValues.includes(name)} {@const label = name ?? "null"} - - + {/each} {/if} @@ -445,11 +470,12 @@ {#each uncheckedItems as name (name)} {@const selected = effectiveSelectedValues.includes(name)} {@const label = name ?? "null"} - - diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte index d1622c79f2bd..788b4ffdf8ae 100644 --- a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte @@ -9,6 +9,7 @@ import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store.ts"; import FilterButton from "@rilldata/web-common/features/dashboards/filters/FilterButton.svelte"; import { Button } from "@rilldata/web-common/components/button"; + import MeasureFilter from "@rilldata/web-common/features/dashboards/filters/manager/MeasureFilter.svelte"; const StateManagers = getStateManagers(); const { @@ -18,7 +19,7 @@ selectors: { dimensions: { allDimensions }, dimensionFilters: { dimensionHasFilter }, - measures: { filteredSimpleMeasures }, + measures: { allMeasures, filteredSimpleMeasures }, measureFilters: { measureHasFilter }, }, validSpecStore, @@ -28,6 +29,10 @@ let validExplore = $derived($validSpecStore.data?.explore ?? {}); let metricsViewName = $derived(validExplore.metricsView ?? ""); + let measureIdMap = $derived( + getMapFromArray($allMeasures, (measure) => measure.name as string), + ); + let dimensionIdMap = $derived( getMapFromArray( $allDimensions, @@ -43,6 +48,7 @@ $effect(() => manager.setExprParam( filterUrlStore.value ?? "", + measureIdMap, dimensionIdMap, metricsViewName, ), @@ -70,6 +76,15 @@ {timeEnd} {timeControlsReady} timeDimension={selectedTimeDimension} + openOnMount={manager.temporaryFilter === dimensionManager} + /> + {/each} + + {#each manager.measureFilterManagers as measureManager (measureManager.name)} + {/each} diff --git a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte new file mode 100644 index 000000000000..2ba1aafdf36d --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte @@ -0,0 +1,147 @@ + + + { + if (!open) return; + if (measureManager.pinned !== curPinned) { + measureManager.togglePinned(); + } + if (measureManager.required !== curRequired) { + measureManager.toggleRequired(); + } + }} +> + + {#snippet child({ props })} + + measureManager.clear()} + removable={!curPinned && !measureManager.required} + removeTooltipText={m.dashboard_remove_label({ + label: measureManager.label, + })} + > + { + return d.name === measureManager.dimension; + })?.displayName ?? ""} + {filter} + label={measureManager.label} + slot="body" + /> + +
+ + + {measureManager.name} + {measureManager.required + ? m.dashboard_required_measure() + : measureManager.label || ""} + + + {#if missingRequired} + {m.dashboard_filter_required_set_value()} + {:else} + + {m.dashboard_click_to_edit_values()} + + {/if} + +
+
+ {/snippet} +
+ + {#if open} + onApply(dimension, filter)} + bind:pinned={curPinned} + bind:required={curRequired} + showPinControl={measureManager.editing} + showRequiredControl={measureManager.editing} + {side} + /> + {/if} +
diff --git a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts index c16dfb2501f0..937ced2e78f8 100644 --- a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts @@ -8,7 +8,6 @@ import { } from "@rilldata/web-common/runtime-client"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts"; import { - copyFilterExpression, createInExpression, createLikeExpression, getValuesInExpression, @@ -58,7 +57,9 @@ export class DimensionFilterManager { op === V1Operation.OPERATION_NLIKE ) { initMode = DimensionFilterMode.Contains; - initInputText = initExpr.cond?.exprs?.[1]?.val?.toString?.() ?? ""; + initInputText = sanitizeSearchText( + initExpr.cond?.exprs?.[1]?.val?.toString?.() ?? "", + ); initExclude = op === V1Operation.OPERATION_NLIKE; } @@ -170,3 +171,7 @@ export class DimensionFilterManager { this.oldMode = this.mode; } } + +export function sanitizeSearchText(searchText: string) { + return searchText.replace(/^%/, "").replace(/%$/, ""); +} diff --git a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts index d0b4fc020e70..a4952f1fa523 100644 --- a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts @@ -1,41 +1,46 @@ -import { - DimensionFilterManager, - type DimensionFilterManagerInit, -} from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; +import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; import type { V1Expression } from "@rilldata/web-admin/client"; import { convertFilterParamToExpression } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; import { createAndExpression, - createInExpression, forEachIdentifier, - getValuesInExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; import { type MetricsViewSpecDimension, - V1Operation, + type MetricsViewSpecMeasure, } from "@rilldata/web-common/runtime-client"; -import { getDimensionDisplayName } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; -import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; +import { + getDimensionDisplayName, + getMeasureDisplayName, +} from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; +import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/measure-filter-manager.svelte.ts"; export class ExpressionFilterManager { + public measureFilterManagers: MeasureFilterManager[] = $state([]); public dimensionFilterManagers: DimensionFilterManager[] = $state([]); + public temporaryFilter: + | MeasureFilterManager + | DimensionFilterManager + | undefined = $state(undefined); + public expr: V1Expression; public exprParam: string = ""; - private temporaryFilter: DimensionFilterManager | undefined = - $state(undefined); - public constructor() { this.expr = $derived.by(() => { - const exprs = this.dimensionFilterManagers + const dimExprs = this.dimensionFilterManagers .filter((dfm) => !!dfm.expr) .map((d) => d.expr as V1Expression); - return createAndExpression(exprs); + const mesExprs = this.measureFilterManagers + .filter((mfm) => !!mfm.expr) + .map((m) => m.expr as V1Expression); + return createAndExpression(dimExprs.concat(mesExprs)); }); } public setExprParam( exprParam: string, + measureIdMap: Map, dimensionIdMap: Map, metricsViewName: string, ) { @@ -47,30 +52,67 @@ export class ExpressionFilterManager { // TODO: check complex filter if (!expr) { this.dimensionFilterManagers = []; + this.measureFilterManagers = []; return; } + const addedMeasure = new Set(); + const newMeasureFilterManagers: MeasureFilterManager[] = []; + const addedDimension = new Set(); const newDimensionFilterManagers: DimensionFilterManager[] = []; + forEachIdentifier(expr, (e, ident) => { const dim = dimensionIdMap.get(ident); if (!dim) return; - addedDimension.add(ident); - - const isInListMode = dimensionsWithInlistFilter.includes(ident); - newDimensionFilterManagers.push( - new DimensionFilterManager( - ident, - getDimensionDisplayName(dim), - new Map([[metricsViewName, dim]]), - false, - e, - isInListMode, - ), - ); + + const firstValueExpr = e?.cond?.exprs?.[1]; + + if (firstValueExpr?.subquery) { + const measureName = firstValueExpr.subquery.measures?.[0]; + if (!measureName) return; + + const measure = measureIdMap.get(measureName); + if (!measure) return; + + addedMeasure.add(measureName); + + newMeasureFilterManagers.push( + new MeasureFilterManager( + measureName, + getMeasureDisplayName(measure), + new Map([ + [metricsViewName, measure], + ]), + false, + ident, + firstValueExpr, + ), + ); + } else { + addedDimension.add(ident); + + const isInListMode = dimensionsWithInlistFilter.includes(ident); + newDimensionFilterManagers.push( + new DimensionFilterManager( + ident, + getDimensionDisplayName(dim), + new Map([[metricsViewName, dim]]), + false, + e, + isInListMode, + ), + ); + } }); - if (this.temporaryFilter) { + if (this.temporaryFilter instanceof MeasureFilterManager) { + if (!addedMeasure.has(this.temporaryFilter.name)) { + newMeasureFilterManagers.push(this.temporaryFilter); + } else { + this.temporaryFilter = undefined; + } + } else if (this.temporaryFilter instanceof DimensionFilterManager) { if (!addedDimension.has(this.temporaryFilter.name)) { newDimensionFilterManagers.push(this.temporaryFilter); } else { @@ -78,9 +120,30 @@ export class ExpressionFilterManager { } } + this.measureFilterManagers = newMeasureFilterManagers; this.dimensionFilterManagers = newDimensionFilterManagers; } + public addTemporaryMeasureFilter( + name: string, + measureIdMap: Map, + metricsViewName: string, + ) { + const measure = measureIdMap.get(name); + if (!measure) return; + + this.temporaryFilter = new MeasureFilterManager( + name, + getMeasureDisplayName(measure), + new Map([[metricsViewName, measure]]), + false, + ); + this.measureFilterManagers = [ + ...this.measureFilterManagers, + this.temporaryFilter, + ]; + } + public addTemporaryDimensionFilter( name: string, dimensionIdMap: Map, @@ -102,6 +165,7 @@ export class ExpressionFilterManager { } public clear() { + this.measureFilterManagers = []; this.dimensionFilterManagers = []; this.temporaryFilter = undefined; } diff --git a/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts new file mode 100644 index 000000000000..9adb9a28f75b --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts @@ -0,0 +1,90 @@ +import type { + MetricsViewSpecMeasure, + 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"; + +export class MeasureFilterManager { + public expr: V1Expression | undefined = $state(undefined); + + public dimension: string; + public operation: MeasureFilterOperation; + public type: MeasureFilterType; + public value1: string; + public value2: string; + public pinned: boolean; + public required: boolean; + + public constructor( + public readonly name: string, + public readonly label: string, + public readonly measures: Map, + public readonly editing: boolean, // TODO: maybe separate editing, pinned & required? + initDimension: string = "", + initExpr: V1Expression | undefined = undefined, + ) { + const unwrappedHavingFilter = removeWrapperAndOrExpression( + initExpr?.subquery?.having, + ); + const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter); + + this.dimension = $state(initDimension); + this.operation = $state( + mappedMeasureFilter?.operation ?? MeasureFilterOperation.LessThan, + ); + this.type = $state(mappedMeasureFilter?.type ?? MeasureFilterType.Value); + this.value1 = $state(mappedMeasureFilter?.value1 ?? ""); + this.value2 = $state(mappedMeasureFilter?.value2 ?? ""); + this.pinned = $state(false); + this.required = $state(false); + this.commit(); + } + + public apply(dimension: string, newFilter: MeasureFilterEntry) { + this.dimension = dimension; + this.operation = $state(newFilter.operation); + this.type = $state(newFilter.type); + this.value1 = $state(newFilter.value1); + this.value2 = $state(newFilter.value2); + this.commit(); + } + + public togglePinned() { + this.pinned = !this.pinned; + } + + public toggleRequired() { + this.required = !this.required; + } + + public clear() { + this.expr = undefined; + 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; + } +} diff --git a/web-common/src/lib/store-utils/url-params-state.svelte.ts b/web-common/src/lib/store-utils/url-params-state.svelte.ts index b1b1cfa32dad..7c29a88096ab 100644 --- a/web-common/src/lib/store-utils/url-params-state.svelte.ts +++ b/web-common/src/lib/store-utils/url-params-state.svelte.ts @@ -76,6 +76,8 @@ function flushParams() { if (newParams.length === 0) return; const newUrl = new SvelteURL(page.url); + const curUrlSearch = page.url.search; + newParams.forEach(([key, value]) => { if (value === null) { newUrl.searchParams.delete(key); @@ -83,7 +85,8 @@ function flushParams() { newUrl.searchParams.set(key, value); } }); - newParams = []; + + if (curUrlSearch === newUrl.search) return; void goto(newUrl, { noScroll: true, keepFocus: true }); } From bc00a722661f1e580cbcbec94e84850cdc17280a Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Fri, 24 Jul 2026 16:42:14 +0530 Subject: [PATCH 04/21] Use the new manager across the explore --- .../GlobalDimensionSearchResults.svelte | 9 +- .../dimension-table/DimensionDisplay.svelte | 24 ++- .../dimension-table/DimensionHeader.svelte | 41 ++-- .../dimension-table/DimensionTable.svelte | 181 ++++++++++-------- .../dashboards/filters/Filters.svelte | 178 +---------------- .../dimension-filters/DimensionFilter.svelte | 4 +- .../filters/manager/DimensionFilter.svelte | 23 +-- .../filters/manager/ExpressionFilters.svelte | 101 ++++++---- .../dimension-filter-manager.svelte.ts | 25 ++- .../expression-filter-manager.svelte.ts | 75 ++++++-- .../manager/measure-filter-manager.svelte.ts | 8 +- .../leaderboard/LeaderboardDisplay.svelte | 14 +- .../actions/dimension-filters.ts | 112 +---------- .../loaders/DashboardStateManager.svelte | 5 + .../loaders/DashboardStateSync.ts | 17 +- .../selectors/dimension-filters.ts | 114 ----------- .../state-managers/state-managers.ts | 3 + .../time-dimension-details/TDDHeader.svelte | 99 ++++++---- .../TimeDimensionDisplay.svelte | 144 ++++++++------ .../MetricsTimeSeriesCharts.svelte | 9 +- .../dashboards/workspace/Dashboard.svelte | 4 - .../store-utils/url-params-state.svelte.ts | 4 - 22 files changed, 489 insertions(+), 705 deletions(-) diff --git a/web-common/src/features/dashboards/dimension-search/GlobalDimensionSearchResults.svelte b/web-common/src/features/dashboards/dimension-search/GlobalDimensionSearchResults.svelte index ed56ae48532e..35c9e5de1daa 100644 --- a/web-common/src/features/dashboards/dimension-search/GlobalDimensionSearchResults.svelte +++ b/web-common/src/features/dashboards/dimension-search/GlobalDimensionSearchResults.svelte @@ -19,12 +19,10 @@ export let open: boolean; const { - actions: { - dimensionsFilter: { toggleDimensionValueSelection }, - }, timeRangeSummaryStore, metricsViewName, validSpecStore, + expressionFilterManager, } = getStateManagers(); const client = useRuntimeClient(); @@ -49,7 +47,10 @@ function onItemSelect(dimension: string, value: any) { onSelect(); - toggleDimensionValueSelection(dimension, value, false, true); + expressionFilterManager.dimensionFilterAction( + dimension, + (dimensionManager) => dimensionManager.toggleValue(value, false), + ); } diff --git a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte index a780b6bb6b61..6d8bc32e24d9 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte @@ -47,7 +47,6 @@ const { selectors: { - dimensionFilters: { unselectedDimensionValues }, dimensionTable: { virtualizedTableColumns, prepareDimTableRows }, sorting: { sortedAscending, sortType }, leaderboard: { @@ -55,15 +54,9 @@ leaderboardSortByMeasureName, }, }, - actions: { - dimensionsFilter: { - toggleDimensionValueSelection, - selectItemsInFilter, - deselectItemsInFilter, - }, - }, dashboardStore, validSpecStore, + expressionFilterManager, } = getStateManagers(); $: metricsViewSpec = $validSpecStore.data?.metricsView ?? {}; @@ -200,25 +193,30 @@ function onSelectItem(data: { index: number; meta: boolean }) { const label = tableRows[data.index][dimensionName] as string; - toggleDimensionValueSelection(dimensionName, label, false, data.meta); + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.toggleValue(label, false), + ); } function toggleAllSearchItems() { const labels = tableRows.map((row) => row[dimensionName] as string); if (areAllTableRowsSelected) { - deselectItemsInFilter(dimensionName, labels); + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.removeSelectedValues(labels), + ); eventBus.emit("notification", { message: `Removed ${labels.length} items from filter`, }); return; } else { - const newValuesSelected = $unselectedDimensionValues( + const newValuesSelected = expressionFilterManager.dimensionFilterAction( dimensionName, - labels, + (dimensionManager) => dimensionManager.appendSelectedValues(labels), ); - selectItemsInFilter(dimensionName, labels); eventBus.emit("notification", { message: `Added ${newValuesSelected.length} items to filter`, }); diff --git a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte index 5efabf64269d..d9d5266ac715 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte @@ -18,39 +18,51 @@ import StartPivotButton from "../toolbars/StartPivotButton.svelte"; import { getDimensionTableExportQuery } from "./dimension-table-export"; - export let dimensionName: string; - export let areAllTableRowsSelected = false; - export let isRowsEmpty = true; - export let searchText: string; - export let hideStartPivotButton = false; - export let onToggleSearchItems: () => void; + let { + dimensionName, + areAllTableRowsSelected, + isRowsEmpty, + searchText = $bindable(""), + hideStartPivotButton, + onToggleSearchItems, + }: { + dimensionName: string; + areAllTableRowsSelected: boolean; + isRowsEmpty: boolean; + searchText: string; + hideStartPivotButton: boolean; + onToggleSearchItems: () => void; + } = $props(); const stateManagers = getStateManagers(); const { selectors: { sorting: { sortedByDimensionValue }, dimensions: { getDimensionDisplayName }, - dimensionFilters: { isFilterExcludeMode }, measures: { visibleMeasures }, }, actions: { sorting: { toggleSort }, dimensions: { setPrimaryDimension }, - dimensionsFilter: { toggleDimensionFilterMode }, leaderboard: { toggleLeaderboardShowContextForAllMeasures }, }, timeRangeSummaryStore, dashboardStore, exploreName, + expressionFilterManager, } = stateManagers; const { adminServer, exports } = featureFlags; - $: exploreHasTimeDimension = !!$timeRangeSummaryStore.data; + let exploreHasTimeDimension = $derived(!!$timeRangeSummaryStore.data); - $: excludeMode = $isFilterExcludeMode(dimensionName); + let excludeMode = $derived( + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimensionName, + )?.exclude ?? false, + ); - let isLeaderboardActionsOpen = false; + let isLeaderboardActionsOpen = $state(false); function resetSearchText() { searchText = ""; @@ -72,10 +84,13 @@ setPrimaryDimension(""); }; function toggleFilterMode() { - toggleDimensionFilterMode(dimensionName); + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.toggleExclude(), + ); } - let showReplacePivotModal = false; + let showReplacePivotModal = $state(false); function startPivotForDimensionTable() { const pivot = $dashboardStore?.pivot; diff --git a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte index ef722326dafb..6e76a01f64a3 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte @@ -1,4 +1,4 @@ - - {#if hasFilters} - - {/if} - {/if} -
-
+
diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte index c26e3a789511..87fe704e2545 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte @@ -303,8 +303,8 @@ } } - function handleToggleExcludeMode(checked: boolean) { - curExcludeMode = checked; + function handleToggleExcludeMode() { + curExcludeMode = !curExcludeMode; excludeModeDirty = true; } diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte index be51f15bc230..19a6719fa696 100644 --- a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte @@ -30,6 +30,7 @@ getDimensionSearchQuery, } from "@rilldata/web-common/features/dashboards/filters/manager/queries.svelte.ts"; import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; + import { onMount } from "svelte"; let { manager, @@ -59,16 +60,8 @@ let proxyDimensionManager = $derived(dimensionManager.clone()); - let open = $state( - // eslint-disable-next-line svelte/valid-compile - openOnMount && - // eslint-disable-next-line svelte/valid-compile - !dimensionManager.selectedValues?.length && - // eslint-disable-next-line svelte/valid-compile - !dimensionManager.inputText, - ); - // eslint-disable-next-line svelte/valid-compile - let curSearchText = $state(dimensionManager.inputText ?? ""); + let open = $state(false); + let curSearchText = $state(""); let inListTooLong = $state(false); const client = useRuntimeClient(); @@ -277,10 +270,18 @@ function handleItemClick(value: string) { proxyDimensionManager.toggleValue(value, false); } + + onMount(() => { + open = + openOnMount && + !dimensionManager.selectedValues?.length && + !dimensionManager.inputText; + curSearchText = dimensionManager.inputText; + }); { + onkeydown={(e) => { if (e.key === "Enter") onApply(); }} /> diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte index 788b4ffdf8ae..c33ab230dfd7 100644 --- a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte @@ -1,15 +1,22 @@
- {#each manager.dimensionFilterManagers as dimensionManager (dimensionManager.name)} - - {/each} + {#if !hasFilters} +
+ {m.dashboard_no_filters_selected()} +
+ {:else} + {#each expressionFilterManager.dimensionFilterManagers as dimensionManager (dimensionManager.name)} + + isUrlTooLongAfterInListFilter(dimensionManager.name, values) + : undefined} + /> + {/each} - {#each manager.measureFilterManagers as measureManager (measureManager.name)} - - {/each} + {#each expressionFilterManager.measureFilterManagers as measureManager (measureManager.name)} + + {/each} + {/if} - manager.addTemporaryDimensionFilter( - name, - dimensionIdMap, - metricsViewName, - )} + setTemporaryFilterName={handleAddNew} /> {#if hasFilters} - {/if} diff --git a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts index 937ced2e78f8..21b187cd163c 100644 --- a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts @@ -13,13 +13,6 @@ import { getValuesInExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; -export type DimensionFilterManagerInit = { - mode: DimensionFilterMode; - selectedValues: string[]; - inputText: string; - exclude: boolean; -}; - export class DimensionFilterManager { public expr: V1Expression | undefined = $state(undefined); @@ -102,11 +95,27 @@ export class DimensionFilterManager { this.selectedValues = [...this.selectedValues, dimensionValue]; } } else { - this.selectedValues = this.selectedValues.splice(inIdx, 1); + 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; diff --git a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts index a4952f1fa523..dcc162fb7c54 100644 --- a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts @@ -26,6 +26,10 @@ export class ExpressionFilterManager { public expr: V1Expression; public exprParam: string = ""; + private metricsViewName: string = ""; + private measureIdMap: Map = new Map(); + private dimensionIdMap: Map = new Map(); + public constructor() { this.expr = $derived.by(() => { const dimExprs = this.dimensionFilterManagers @@ -38,12 +42,17 @@ export class ExpressionFilterManager { }); } - public setExprParam( - exprParam: string, + public syncSpec( + metricsViewName: string, measureIdMap: Map, dimensionIdMap: Map, - metricsViewName: string, ) { + this.metricsViewName = metricsViewName; + this.measureIdMap = measureIdMap; + this.dimensionIdMap = dimensionIdMap; + } + + public setExprParam(exprParam: string) { if (exprParam === this.exprParam) return; this.exprParam = exprParam; @@ -63,7 +72,7 @@ export class ExpressionFilterManager { const newDimensionFilterManagers: DimensionFilterManager[] = []; forEachIdentifier(expr, (e, ident) => { - const dim = dimensionIdMap.get(ident); + const dim = this.dimensionIdMap.get(ident); if (!dim) return; const firstValueExpr = e?.cond?.exprs?.[1]; @@ -72,7 +81,7 @@ export class ExpressionFilterManager { const measureName = firstValueExpr.subquery.measures?.[0]; if (!measureName) return; - const measure = measureIdMap.get(measureName); + const measure = this.measureIdMap.get(measureName); if (!measure) return; addedMeasure.add(measureName); @@ -82,7 +91,7 @@ export class ExpressionFilterManager { measureName, getMeasureDisplayName(measure), new Map([ - [metricsViewName, measure], + [this.metricsViewName, measure], ]), false, ident, @@ -97,7 +106,9 @@ export class ExpressionFilterManager { new DimensionFilterManager( ident, getDimensionDisplayName(dim), - new Map([[metricsViewName, dim]]), + new Map([ + [this.metricsViewName, dim], + ]), false, e, isInListMode, @@ -124,18 +135,16 @@ export class ExpressionFilterManager { this.dimensionFilterManagers = newDimensionFilterManagers; } - public addTemporaryMeasureFilter( - name: string, - measureIdMap: Map, - metricsViewName: string, - ) { - const measure = measureIdMap.get(name); + public addTemporaryMeasureFilter(name: string) { + const measure = this.measureIdMap.get(name); if (!measure) return; this.temporaryFilter = new MeasureFilterManager( name, getMeasureDisplayName(measure), - new Map([[metricsViewName, measure]]), + new Map([ + [this.metricsViewName, measure], + ]), false, ); this.measureFilterManagers = [ @@ -144,18 +153,14 @@ export class ExpressionFilterManager { ]; } - public addTemporaryDimensionFilter( - name: string, - dimensionIdMap: Map, - metricsViewName: string, - ) { - const dim = dimensionIdMap.get(name); + public addTemporaryDimensionFilter(name: string) { + const dim = this.dimensionIdMap.get(name); if (!dim) return; this.temporaryFilter = new DimensionFilterManager( name, getDimensionDisplayName(dim), - new Map([[metricsViewName, dim]]), + new Map([[this.metricsViewName, dim]]), false, ); this.dimensionFilterManagers = [ @@ -164,6 +169,34 @@ export class ExpressionFilterManager { ]; } + public dimensionFilterAction( + name: string, + callback: (dimensionFilterManager: DimensionFilterManager) => any, + ) { + const dim = this.dimensionIdMap.get(name); + if (!dim) return; + + const existingManager = this.dimensionFilterManagers.find( + (dfm) => dfm.name === name, + ); + const dimensionFilterManager = + existingManager ?? + new DimensionFilterManager( + name, + getDimensionDisplayName(dim), + new Map([ + [this.metricsViewName, dim], + ]), + false, + ); + + const ret = callback(dimensionFilterManager); + if (!existingManager && dimensionFilterManager.expr) { + this.dimensionFilterManagers.push(dimensionFilterManager); + } + return ret; + } + public clear() { this.measureFilterManagers = []; this.dimensionFilterManagers = []; diff --git a/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts index 9adb9a28f75b..a213531b1795 100644 --- a/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts @@ -54,10 +54,10 @@ export class MeasureFilterManager { public apply(dimension: string, newFilter: MeasureFilterEntry) { this.dimension = dimension; - this.operation = $state(newFilter.operation); - this.type = $state(newFilter.type); - this.value1 = $state(newFilter.value1); - this.value2 = $state(newFilter.value2); + this.operation = newFilter.operation; + this.type = newFilter.type; + this.value1 = newFilter.value1; + this.value2 = newFilter.value2; this.commit(); } diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte index d8b0af4a7f86..d73fbd66c99a 100644 --- a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte @@ -35,7 +35,6 @@ measureTooltipFormatters, activeMeasureTooltipFormatter, }, - dimensionFilters: { isFilterExcludeMode }, dimensions: { visibleDimensions }, comparison: { isBeingCompared: isBeingComparedReadable }, sorting: { sortedAscending, sortType }, @@ -49,11 +48,11 @@ actions: { dimensions: { setPrimaryDimension }, sorting: { toggleSort }, - dimensionsFilter: { toggleDimensionValueSelection }, comparison: { toggleComparisonDimension }, }, exploreName, dashboardStore, + expressionFilterManager, } = StateManagers; const client = useRuntimeClient(); @@ -111,7 +110,9 @@ {dimensionColumnWidth} sortedAscending={$sortedAscending} sortType={$sortType} - filterExcludeMode={$isFilterExcludeMode(dimension.name)} + filterExcludeMode={expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimension.name, + )?.exclude ?? false} {comparisonTimeRange} {dimension} {parentElement} @@ -136,7 +137,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/state-managers/actions/dimension-filters.ts b/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts index e7a0c1a067f6..03201faec7bb 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,11 +1,9 @@ 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 { @@ -171,28 +169,6 @@ export function applyDimensionContainsMode( } } -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, @@ -207,74 +183,6 @@ export function removeDimensionFilter( 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, @@ -303,22 +211,4 @@ export function toggleDimensionFilterValue( 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/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 7033c60e9d11..2d3569e039d4 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -21,6 +21,7 @@ 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); @@ -55,6 +56,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( @@ -186,7 +188,10 @@ export class DashboardStateSync { ); } - // log("INIT", redirectUrl); + log("INIT", redirectUrl); + this.expressionFilterManager.setExprParam( + redirectUrl.searchParams.get(ExploreStateURLParams.Filters) ?? "", + ); // 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; @@ -296,7 +301,10 @@ export class DashboardStateSync { this.updating = false; } - // log("URL", redirectUrl); + log("URL", redirectUrl); + this.expressionFilterManager.setExprParam( + redirectUrl.searchParams.get(ExploreStateURLParams.Filters) ?? "", + ); // If the url doesn't need to be changed further then we can skip the goto if (redirectUrl.search === pageState.url.search) { return; @@ -352,7 +360,10 @@ export class DashboardStateSync { ); } - // log("GOTO", newUrl); + log("GOTO", newUrl); + this.expressionFilterManager.setExprParam( + newUrl.searchParams.get(ExploreStateURLParams.Filters) ?? "", + ); // 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) { 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..20eef1c8fa73 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,7 +1,6 @@ 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, @@ -97,13 +96,6 @@ 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, ) => { @@ -144,19 +136,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 +209,9 @@ 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, }; 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..487d75c88924 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,7 @@ import { contextColWidthDefaults, type ContextColWidths, } from "../leaderboard-context-column"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; export type StateManagers = { runtimeClient: RuntimeClient; @@ -65,6 +66,7 @@ export type StateManagers = { */ contextColumnWidths: Writable; defaultExploreState: Readable; + expressionFilterManager: ExpressionFilterManager; }; export const DEFAULT_STORE_KEY = Symbol("state-managers"); @@ -191,5 +193,6 @@ export function createStateManagers({ }), contextColumnWidths, defaultExploreState, + expressionFilterManager: new ExpressionFilterManager(), }; } diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte index 9b47263de2d1..676c72594dbc 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte @@ -30,15 +30,29 @@ import type { TDDComparison } from "./types"; import { V1TimeGrainToDateTimeUnit } from "@rilldata/web-common/lib/time/new-grains"; - export let exploreName: string; - export let dimensionName: string; - export let isFetching = false; - export let comparing: TDDComparison | undefined; - export let areAllTableRowsSelected = false; - export let isRowsEmpty = false; - export let expandedMeasureName: string; - export let onToggleSearchItems: () => void; - export let hideStartPivotButton = false; + interface Props { + exploreName: string; + dimensionName: string; + isFetching?: boolean; + comparing: TDDComparison | undefined; + areAllTableRowsSelected?: boolean; + isRowsEmpty?: boolean; + expandedMeasureName: string; + onToggleSearchItems: () => void; + hideStartPivotButton?: boolean; + } + + let { + exploreName, + dimensionName, + isFetching = false, + comparing, + areAllTableRowsSelected = false, + isRowsEmpty = false, + expandedMeasureName, + onToggleSearchItems, + hideStartPivotButton = false, + }: Props = $props(); const { adminServer, exports } = featureFlags; const stateManagers = getStateManagers(); @@ -48,30 +62,34 @@ measures: { measureLabel, allMeasures }, dimensions: { getDimensionDisplayName }, }, - actions: { - dimensionsFilter: { toggleDimensionFilterMode }, - }, dashboardStore, validSpecStore, + expressionFilterManager, } = stateManagers; - $: selectableMeasures = $allMeasures - .filter((m) => m.name !== undefined || m.displayName !== undefined) - .map((m) => - // Note: undefined values are filtered out above, so the - // empty string fallback is unreachable. - ({ - name: m.name || "", - label: m.displayName || "", - }), - ); + const selectableMeasures = $derived( + $allMeasures + .filter((m) => m.name !== undefined || m.displayName !== undefined) + .map((m) => + // Note: undefined values are filtered out above, so the + // empty string fallback is unreachable. + ({ + name: m.name || "", + label: m.displayName || "", + }), + ), + ); - $: selectedMeasureLabel = + const selectedMeasureLabel = $derived( $allMeasures.find((m) => m.name === expandedMeasureName)?.displayName || - expandedMeasureName; + expandedMeasureName, + ); - $: excludeMode = - $dashboardStore?.dimensionFilterExcludeMode.get(dimensionName) ?? false; + const excludeMode = $derived( + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimensionName, + )?.exclude ?? false, + ); function closeSearchBar() { dimensionSearchText.set(""); @@ -85,14 +103,17 @@ } function toggleFilterMode() { - toggleDimensionFilterMode(dimensionName); + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.toggleExclude(), + ); } function switchMeasure(measureName: string) { metricsExplorerStore.setExpandedMeasureName(exploreName, measureName); } - let showReplacePivotModal = false; + let showReplacePivotModal = $state(false); function startPivotForTDD() { const pivot = $dashboardStore?.pivot; @@ -139,17 +160,21 @@ const timeControlsStore = useTimeControlStore(stateManagers); - $: ({ minTimeGrain, timeStart, timeEnd, selectedTimeRange } = - $timeControlsStore); + const minTimeGrain = $derived($timeControlsStore.minTimeGrain); + const timeStart = $derived($timeControlsStore.timeStart); + const timeEnd = $derived($timeControlsStore.timeEnd); + const selectedTimeRange = $derived($timeControlsStore.selectedTimeRange); - $: activeTimeGrain = selectedTimeRange?.interval; + const activeTimeGrain = $derived(selectedTimeRange?.interval); - $: baseTimeRange = selectedTimeRange?.start && - selectedTimeRange?.end && { - name: selectedTimeRange?.name, - start: selectedTimeRange.start, - end: selectedTimeRange.end, - }; + const baseTimeRange = $derived( + selectedTimeRange?.start && + selectedTimeRange?.end && { + name: selectedTimeRange?.name, + start: selectedTimeRange.start, + end: selectedTimeRange.end, + }, + ); function onTimeGrainSelect(timeGrain: V1TimeGrain) { if (baseTimeRange) { diff --git a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte index 81d0b160b726..4edf67613f33 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte @@ -23,84 +23,105 @@ import type { TDDComparison, TableData } from "./types"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; - export let exploreName: string; - export let expandedMeasureName: string; - export let hideStartPivotButton = false; + interface Props { + exploreName: string; + expandedMeasureName: string; + hideStartPivotButton?: boolean; + } + + let { + exploreName, + expandedMeasureName, + hideStartPivotButton = false, + }: Props = $props(); - $: isEmbedded = EmbedStore.isEmbedded(); + const isEmbedded = $derived(EmbedStore.isEmbedded()); const stateManagers = getStateManagers(); const { dashboardStore, selectors: { dimensions: { allDimensions }, - dimensionFilters: { unselectedDimensionValues }, measures: { allMeasures }, }, actions: { - dimensionsFilter: { - toggleDimensionValueSelection, - selectItemsInFilter, - deselectItemsInFilter, - }, sorting: { toggleSort }, }, + expressionFilterManager, } = getStateManagers(); const timeDimensionDataStore = useTimeDimensionDataStore(stateManagers); const timeControlStore = useTimeControlStore(stateManagers); - $: dimensionName = $dashboardStore?.selectedComparisonDimension ?? ""; - $: comparing = $timeDimensionDataStore?.comparing; + const dimensionName = $derived( + $dashboardStore?.selectedComparisonDimension ?? "", + ); + const comparing = $derived($timeDimensionDataStore?.comparing); - $: pinIndex = $dashboardStore?.tdd.pinIndex; + const pinIndex = $derived($dashboardStore?.tdd.pinIndex); - $: timeGrain = $timeControlStore.selectedTimeRange?.interval; + const timeGrain = $derived($timeControlStore.selectedTimeRange?.interval); - $: measure = $allMeasures.find((m) => m.name === expandedMeasureName); + const measure = $derived( + $allMeasures.find((m) => m.name === expandedMeasureName), + ); - $: measureLabel = measure?.displayName ?? ""; + const measureLabel = $derived(measure?.displayName ?? ""); - let dimensionLabel = ""; - $: if (comparing === "dimension") { - dimensionLabel = - $allDimensions.find((d) => d.name === dimensionName)?.displayName ?? ""; - } else if (comparing === "time") { - dimensionLabel = m.dashboard_tdd_time(); - } else if (comparing === "none") { - dimensionLabel = m.dashboard_tdd_no_comparison(); - } + const dimensionLabel = $derived.by(() => { + if (comparing === "dimension") { + return ( + $allDimensions.find((d) => d.name === dimensionName)?.displayName ?? "" + ); + } else if (comparing === "time") { + return m.dashboard_tdd_time(); + } else if (comparing === "none") { + return m.dashboard_tdd_no_comparison(); + } + return ""; + }); // Create a copy of the data to avoid flashing of table in transient states - let timeDimensionDataCopy: TableData; - let comparisonCopy: TDDComparison | undefined; - $: if ( - $timeDimensionDataStore?.data && - $timeDimensionDataStore?.data?.columnHeaderData - ) { - comparisonCopy = comparing; - timeDimensionDataCopy = $timeDimensionDataStore.data; - } - $: formattedData = timeDimensionDataCopy; - $: excludeMode = - $dashboardStore?.dimensionFilterExcludeMode.get(dimensionName) ?? false; + let timeDimensionDataCopy = $state(); + let comparisonCopy = $state(); + $effect(() => { + if ( + $timeDimensionDataStore?.data && + $timeDimensionDataStore?.data?.columnHeaderData + ) { + comparisonCopy = comparing; + timeDimensionDataCopy = $timeDimensionDataStore.data; + } + }); + const formattedData = $derived(timeDimensionDataCopy); + const excludeMode = $derived( + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimensionName, + )?.exclude ?? false, + ); - $: rowHeaderLabels = - formattedData?.rowHeaderData?.slice(1)?.map((row) => row[0]?.value) ?? []; + const rowHeaderLabels = $derived( + formattedData?.rowHeaderData?.slice(1)?.map((row) => row[0]?.value) ?? [], + ); - $: areAllTableRowsSelected = rowHeaderLabels?.every( - (val) => val !== undefined && formattedData?.selectedValues?.includes(val), + const areAllTableRowsSelected = $derived( + rowHeaderLabels?.every( + (val) => + val !== undefined && formattedData?.selectedValues?.includes(val), + ), ); - $: columnHeaders = formattedData?.columnHeaderData?.flat(); + const columnHeaders = $derived(formattedData?.columnHeaderData?.flat()); - $: highlightedColStart = $hoverIndex?.start; - $: highlightedColEnd = $hoverIndex?.end; + const highlightedColStart = $derived($hoverIndex?.start); + const highlightedColEnd = $derived($hoverIndex?.end); // Create a time formatter for the column headers - $: timeFormatter = timeFormat( - timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", - ) as (d: Date) => string; + const timeFormatter = $derived( + timeFormat( + timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", + ) as (d: Date) => string, + ); function highlightCell(x: number | undefined, y: number | undefined) { if (x === undefined || y === undefined) { @@ -127,8 +148,11 @@ const debounceHighlightCell = debounce(highlightCell, 50); - function toggleFilter(label: string | null) { - toggleDimensionValueSelection(dimensionName, label); + function toggleFilter(label: string) { + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.toggleValue(label, false), + ); } function toggleAllSearchItems() { @@ -139,9 +163,10 @@ if (headerHasUndefined) return; if (areAllTableRowsSelected) { - deselectItemsInFilter( + expressionFilterManager.dimensionFilterAction( dimensionName, - rowHeaderLabels as (string | null)[], + (dimensionManager) => + dimensionManager.removeSelectedValues(rowHeaderLabels as string[]), ); eventBus.emit("notification", { @@ -151,11 +176,11 @@ }); return; } else { - const newValuesSelected = $unselectedDimensionValues( + const newValuesSelected = expressionFilterManager.dimensionFilterAction( dimensionName, - rowHeaderLabels, + (dimensionManager) => + dimensionManager.appendSelectedValues(rowHeaderLabels as string[]), ); - selectItemsInFilter(dimensionName, rowHeaderLabels as (string | null)[]); eventBus.emit("notification", { message: m.dashboard_added_items_filter({ count: newValuesSelected.length.toString(), @@ -166,23 +191,24 @@ function togglePin() { let newPinIndex = -1; + const selectedCount = formattedData?.selectedValues?.length ?? 0; // Pin if some selected items are not pinned yet - if (pinIndex > -1 && pinIndex < formattedData?.selectedValues?.length - 1) { - newPinIndex = formattedData?.selectedValues?.length - 1; + if (pinIndex > -1 && pinIndex < selectedCount - 1) { + newPinIndex = selectedCount - 1; } // Pin if no items are pinned yet else if (pinIndex === -1) { - newPinIndex = formattedData?.selectedValues?.length - 1; + newPinIndex = selectedCount - 1; } metricsExplorerStore.setPinIndex(exploreName, newPinIndex); } - function handleKeyDown(e) { + function handleKeyDown(e: KeyboardEvent) { if (comparisonCopy !== "dimension") return; // Select all items on Meta+A if ((e.ctrlKey || e.metaKey) && e.key === "a") { - if (e.target.tagName === "INPUT") return; + if ((e.target as HTMLElement)?.tagName === "INPUT") return; e.preventDefault(); if (areAllTableRowsSelected) return; toggleAllSearchItems(); diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 1a4832521b3b..324782dcd285 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -66,13 +66,13 @@ dashboardStore, selectors: { measures: { allMeasures, visibleMeasures, getMeasureByName }, - dimensionFilters: { includedDimensionValues }, charts: { canPanLeft, canPanRight, getNewPanRange }, tags: { measureTagIndex }, }, actions: { measures: { setMeasureVisibility }, }, + expressionFilterManager, } = StateManagers; const timeControlsStore = useTimeControlStore(StateManagers); @@ -148,9 +148,10 @@ const [start, end] = a <= b ? [a, b] : [b, a]; return Interval.fromDateTimes(start, end) as Interval; })(); - $: includedValuesForDimension = $includedDimensionValues( - comparisonDimension as string, - ); + $: includedValuesForDimension = + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === comparisonDimension, + )?.selectedValues ?? []; $: chartDimensionValues = includedValuesForDimension.slice( 0, showTimeDimensionDetail ? 11 : 7, diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 9c48ab4c6094..cbac3e2f4682 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -22,7 +22,6 @@ import MeasuresContainer from "../big-number/MeasuresContainer.svelte"; import DimensionDisplay from "../dimension-table/DimensionDisplay.svelte"; import Filters from "../filters/Filters.svelte"; - import ExpressionFilters from "../filters/manager/ExpressionFilters.svelte"; import { selectedMockUserStore } from "../granular-access-policies/stores"; import LeaderboardDisplay from "../leaderboard/LeaderboardDisplay.svelte"; import RowsViewerAccordion from "../rows-viewer/RowsViewerAccordion.svelte"; @@ -166,9 +165,6 @@
{:else} {#key exploreName} -
- -
diff --git a/web-common/src/lib/store-utils/url-params-state.svelte.ts b/web-common/src/lib/store-utils/url-params-state.svelte.ts index 7c29a88096ab..16697ae04fa1 100644 --- a/web-common/src/lib/store-utils/url-params-state.svelte.ts +++ b/web-common/src/lib/store-utils/url-params-state.svelte.ts @@ -76,8 +76,6 @@ function flushParams() { if (newParams.length === 0) return; const newUrl = new SvelteURL(page.url); - const curUrlSearch = page.url.search; - newParams.forEach(([key, value]) => { if (value === null) { newUrl.searchParams.delete(key); @@ -86,7 +84,5 @@ function flushParams() { } }); newParams = []; - - if (curUrlSearch === newUrl.search) return; void goto(newUrl, { noScroll: true, keepFocus: true }); } From 7918110f3bf2575853e5dc368eef33ab3cfdf3e0 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Fri, 24 Jul 2026 19:33:13 +0530 Subject: [PATCH 05/21] Move alerts/reports to the new filter class --- .../src/features/alerts/AlertForm.svelte | 20 +- .../src/features/alerts/alert-preview-data.ts | 12 +- .../src/features/alerts/create-alert-utils.ts | 19 +- .../AlertDialogCriteriaTab.svelte | 4 +- .../alerts/criteria-tab/AlertPreview.svelte | 41 ++-- .../alerts/data-tab/AlertDialogDataTab.svelte | 4 +- .../alerts/data-tab/DataPreview.svelte | 42 ++-- web-common/src/features/alerts/form-utils.ts | 11 +- .../dashboards/filters/Filters.svelte | 182 ++++++++++++------ .../filters/manager/ExpressionFilters.svelte | 98 +++++----- .../expression-filter-manager.svelte.ts | 15 +- .../state-managers/actions/filters.ts | 2 + .../state-managers/selectors/measures.ts | 36 ++-- .../BaseScheduledReportForm.svelte | 4 +- .../scheduled-reports/FiltersForm.svelte | 160 ++------------- .../ScheduledReportDialog.svelte | 8 +- .../src/features/scheduled-reports/utils.ts | 22 +-- .../store-utils/url-params-state.svelte.ts | 1 + 18 files changed, 323 insertions(+), 358 deletions(-) diff --git a/web-common/src/features/alerts/AlertForm.svelte b/web-common/src/features/alerts/AlertForm.svelte index 76f962c202d0..2ff658deca6e 100644 --- a/web-common/src/features/alerts/AlertForm.svelte +++ b/web-common/src/features/alerts/AlertForm.svelte @@ -185,7 +185,7 @@ queryArgsJson: JSON.stringify( getAlertQueryArgsFromFormValues( values, - filters.toState(), + filters.expr, timeControls.toState(), exploreSpec, ), @@ -324,18 +324,18 @@
{#if currentTabIndex === 0} - + {:else} - + {/if} {#if currentTabIndex !== 2} - + {:else}
{/if} - +
diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte index c33ab230dfd7..66befd97775b 100644 --- a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte @@ -1,59 +1,54 @@
@@ -91,12 +87,13 @@ isUrlTooLongAfterInListFilter(dimensionManager.name, values) @@ -107,9 +104,10 @@ {#each expressionFilterManager.measureFilterManagers as measureManager (measureManager.name)} {/each} {/if} @@ -119,11 +117,12 @@ {filteredSimpleMeasures} {dimensionHasFilter} {measureHasFilter} - setTemporaryFilterName={handleAddNew} + setTemporaryFilterName={(name: string) => + expressionFilterManager.addTemporaryFilter(name)} /> - {#if hasFilters} + {#if hasClearableFilters} diff --git a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte index 2ba1aafdf36d..fde6be148116 100644 --- a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte +++ b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte @@ -10,15 +10,18 @@ import type { MetricsViewSpecDimension } from "@rilldata/web-common/runtime-client"; import MeasureFilterForm from "@rilldata/web-common/features/dashboards/filters/measure-filters/MeasureFilterForm.svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; - import type { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/measure-filter-manager.svelte.ts"; + import type { MeasureFilterManager } from "./MeasureFilterManager.svelte.ts"; + import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; let { measureManager, + yamlConfigProvider, openOnMount = true, allDimensions, side = "bottom", }: { measureManager: MeasureFilterManager; + yamlConfigProvider: YAMLConfigProvider; openOnMount?: boolean; allDimensions: MetricsViewSpecDimension[]; side?: "top" | "right" | "bottom" | "left"; @@ -26,14 +29,20 @@ // svelte-ignore state_referenced_locally let open = $state(openOnMount && !measureManager.expr); - // svelte-ignore state_referenced_locally - let curPinned = $state(measureManager.pinned); - // svelte-ignore state_referenced_locally - let curRequired = $state(measureManager.required); - let missingRequired = $derived( - measureManager.required && !measureManager.expr, + let pinned = $derived( + Boolean(yamlConfigProvider.pinnedFilters[measureManager.name]), + ); + let required = $derived( + Boolean(yamlConfigProvider.requiredFilters[measureManager.name]), ); + let missingRequired = $derived(required && !measureManager.expr); + + // Pinned and required are edited locally and only persisted once the popover closes. + // svelte-ignore state_referenced_locally + let curPinned = $state(pinned); + // svelte-ignore state_referenced_locally + let curRequired = $state(required); let filter = $derived( measureManager.expr @@ -48,25 +57,28 @@ ); function onApply(dimension: string, filter: MeasureFilterEntry) { - if (measureManager.pinned !== curPinned) { - measureManager.togglePinned(); + persistPinnedAndRequired(); + measureManager.apply(dimension, filter); + } + + function persistPinnedAndRequired() { + if (pinned !== curPinned) { + yamlConfigProvider.togglePinnedFilter(measureManager.name); } - if (measureManager.required !== curRequired) { - measureManager.toggleRequired(); + if (required !== curRequired) { + yamlConfigProvider.toggleRequiredFilter(measureManager.name); } - measureManager.apply(dimension, filter); } { - if (!open) return; - if (measureManager.pinned !== curPinned) { - measureManager.togglePinned(); - } - if (measureManager.required !== curRequired) { - measureManager.toggleRequired(); + onOpenChange={(open) => { + if (open) { + curPinned = pinned; + curRequired = required; + } else { + persistPinnedAndRequired(); } }} > @@ -88,7 +100,7 @@ error={!!missingRequired} theme onRemove={() => measureManager.clear()} - removable={!curPinned && !measureManager.required} + removable={!pinned && !required} removeTooltipText={m.dashboard_remove_label({ label: measureManager.label, })} @@ -109,7 +121,7 @@ >{measureManager.name} {measureManager.required + >{required ? m.dashboard_required_measure() : measureManager.label || ""} @@ -139,8 +151,8 @@ onApply={({ dimension, filter }) => onApply(dimension, filter)} bind:pinned={curPinned} bind:required={curRequired} - showPinControl={measureManager.editing} - showRequiredControl={measureManager.editing} + showPinControl={yamlConfigProvider.editable} + showRequiredControl={yamlConfigProvider.editable} {side} /> {/if} diff --git a/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts similarity index 85% rename from web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts rename to web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts index a213531b1795..7dcb8cb4c111 100644 --- a/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts @@ -24,14 +24,11 @@ export class MeasureFilterManager { public type: MeasureFilterType; public value1: string; public value2: string; - public pinned: boolean; - public required: boolean; public constructor( public readonly name: string, public readonly label: string, - public readonly measures: Map, - public readonly editing: boolean, // TODO: maybe separate editing, pinned & required? + public readonly measures: Record, initDimension: string = "", initExpr: V1Expression | undefined = undefined, ) { @@ -47,8 +44,6 @@ export class MeasureFilterManager { this.type = $state(mappedMeasureFilter?.type ?? MeasureFilterType.Value); this.value1 = $state(mappedMeasureFilter?.value1 ?? ""); this.value2 = $state(mappedMeasureFilter?.value2 ?? ""); - this.pinned = $state(false); - this.required = $state(false); this.commit(); } @@ -61,14 +56,6 @@ export class MeasureFilterManager { this.commit(); } - public togglePinned() { - this.pinned = !this.pinned; - } - - public toggleRequired() { - this.required = !this.required; - } - public clear() { this.expr = undefined; this.commit(); diff --git a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts deleted file mode 100644 index f79444cb117c..000000000000 --- a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; -import type { V1Expression } from "@rilldata/web-admin/client"; -import { convertFilterParamToExpression } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; -import { - createAndExpression, - forEachIdentifier, - isExpressionUnsupported, -} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; -import { - type MetricsViewSpecDimension, - type MetricsViewSpecMeasure, -} from "@rilldata/web-common/runtime-client"; -import { - getDimensionDisplayName, - getMeasureDisplayName, -} from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; -import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/measure-filter-manager.svelte.ts"; - -export class ExpressionFilterManager { - public measureFilterManagers: MeasureFilterManager[] = $state([]); - public dimensionFilterManagers: DimensionFilterManager[] = $state([]); - public temporaryFilter: - | MeasureFilterManager - | DimensionFilterManager - | undefined = $state(undefined); - - public expr: V1Expression; - public exprParam: string = ""; - public isComplexFilter: boolean = $state(false); - - public exploreName: string = ""; - public metricsViewName: string = ""; - public measureIdMap: Map = new Map(); - public dimensionIdMap: Map = new Map(); - - public constructor() { - this.expr = $derived.by(() => { - const dimExprs = this.dimensionFilterManagers - .filter((dfm) => !!dfm.expr) - .map((d) => d.expr as V1Expression); - const mesExprs = this.measureFilterManagers - .filter((mfm) => !!mfm.expr) - .map((m) => m.expr as V1Expression); - return createAndExpression(dimExprs.concat(mesExprs)); - }); - } - - public syncSpec( - metricsViewName: string, - exploreName: string, - measureIdMap: Map, - dimensionIdMap: Map, - ) { - this.metricsViewName = metricsViewName; - this.exploreName = exploreName; - this.measureIdMap = measureIdMap; - this.dimensionIdMap = dimensionIdMap; - } - - public setExprParam(exprParam: string) { - if (exprParam === this.exprParam) return; - this.exprParam = exprParam; - - const { expr, dimensionsWithInlistFilter } = - convertFilterParamToExpression(exprParam); - this.isComplexFilter = expr ? isExpressionUnsupported(expr) : false; - if (!expr || this.isComplexFilter) { - this.dimensionFilterManagers = []; - this.measureFilterManagers = []; - return; - } - - const addedMeasure = new Set(); - const newMeasureFilterManagers: MeasureFilterManager[] = []; - - const addedDimension = new Set(); - const newDimensionFilterManagers: DimensionFilterManager[] = []; - - forEachIdentifier(expr, (e, ident) => { - const dim = this.dimensionIdMap.get(ident); - if (!dim) return; - - const firstValueExpr = e?.cond?.exprs?.[1]; - - if (firstValueExpr?.subquery) { - const measureName = firstValueExpr.subquery.measures?.[0]; - if (!measureName) return; - - const measure = this.measureIdMap.get(measureName); - if (!measure) return; - - addedMeasure.add(measureName); - - newMeasureFilterManagers.push( - new MeasureFilterManager( - measureName, - getMeasureDisplayName(measure), - new Map([ - [this.metricsViewName, measure], - ]), - false, - ident, - firstValueExpr, - ), - ); - } else { - addedDimension.add(ident); - - const isInListMode = dimensionsWithInlistFilter.includes(ident); - newDimensionFilterManagers.push( - new DimensionFilterManager( - ident, - getDimensionDisplayName(dim), - new Map([ - [this.metricsViewName, dim], - ]), - false, - e, - isInListMode, - ), - ); - } - }); - - if (this.temporaryFilter instanceof MeasureFilterManager) { - if (!addedMeasure.has(this.temporaryFilter.name)) { - newMeasureFilterManagers.push(this.temporaryFilter); - } else { - this.temporaryFilter = undefined; - } - } else if (this.temporaryFilter instanceof DimensionFilterManager) { - if (!addedDimension.has(this.temporaryFilter.name)) { - newDimensionFilterManagers.push(this.temporaryFilter); - } else { - this.temporaryFilter = undefined; - } - } - - this.measureFilterManagers = newMeasureFilterManagers; - this.dimensionFilterManagers = newDimensionFilterManagers; - } - - public addTemporaryMeasureFilter(name: string) { - const measure = this.measureIdMap.get(name); - if (!measure) return; - - this.temporaryFilter = new MeasureFilterManager( - name, - getMeasureDisplayName(measure), - new Map([ - [this.metricsViewName, measure], - ]), - false, - ); - this.measureFilterManagers = [ - ...this.measureFilterManagers, - this.temporaryFilter, - ]; - } - - public addTemporaryDimensionFilter(name: string) { - const dim = this.dimensionIdMap.get(name); - if (!dim) return; - - this.temporaryFilter = new DimensionFilterManager( - name, - getDimensionDisplayName(dim), - new Map([[this.metricsViewName, dim]]), - false, - ); - this.dimensionFilterManagers = [ - ...this.dimensionFilterManagers, - this.temporaryFilter, - ]; - } - - public dimensionFilterAction( - name: string, - callback: (dimensionFilterManager: DimensionFilterManager) => any, - ) { - const dim = this.dimensionIdMap.get(name); - if (!dim) return; - - const existingManager = this.dimensionFilterManagers.find( - (dfm) => dfm.name === name, - ); - const dimensionFilterManager = - existingManager ?? - new DimensionFilterManager( - name, - getDimensionDisplayName(dim), - new Map([ - [this.metricsViewName, dim], - ]), - false, - ); - - const ret = callback(dimensionFilterManager); - if (!existingManager && dimensionFilterManager.expr) { - this.dimensionFilterManagers.push(dimensionFilterManager); - } - return ret; - } - - public clear() { - this.measureFilterManagers = []; - this.dimensionFilterManagers = []; - this.temporaryFilter = undefined; - } - - public getOtherDimensionsFilter(name: string) { - const exprs = this.dimensionFilterManagers - .filter((dfm) => !!dfm.expr && dfm.name !== name) - .map((d) => d.expr as V1Expression); - return exprs.length === 0 ? undefined : createAndExpression(exprs); - } -} diff --git a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts index 9c4762f4e68a..48c1b8d331f5 100644 --- a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts @@ -1,6 +1,6 @@ import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; -import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; -import type { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +import type { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/DimensionFilterManager.svelte.ts"; import { createQuery } from "@tanstack/svelte-query"; import { getQueryServiceMetricsViewAggregationQueryOptions, @@ -39,7 +39,7 @@ export function getDimensionSearchQuery( }: DimensionSearchArgs, ) { const dimensionName = dimensionManager.name; - const mvName = [...dimensionManager.dimensions.keys()][0] ?? ""; + const mvName = Object.keys(dimensionManager.dimensions)[0] ?? ""; const otherDimsFilter = $derived.by(() => { manager.expr; // Force rederivation when expr changes. @@ -91,7 +91,7 @@ export function getAllSearchResultsCount( }: DimensionSearchArgs, ) { const dimensionName = dimensionManager.name; - const mvName = [...dimensionManager.dimensions.keys()][0] ?? ""; + const mvName = Object.keys(dimensionManager.dimensions)[0] ?? ""; const countMeasureName = dimensionName + "__distinct_count"; const otherDimsFilter = $derived.by(() => { 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..98da5a5baf21 --- /dev/null +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -0,0 +1,39 @@ +import type { V1CanvasSpec } from "@rilldata/web-common/runtime-client"; + +/** + * A provider for YAML only configuration. These are only mutable during yaml editing. + */ +export class YAMLConfigProvider { + public pinnedFilters = $state>({}); + public specPinnedFilters = $state>({}); + public requiredFilters = $state>({}); + public specRequiredFilters = $state>({}); + + public constructor(public readonly editable: boolean) {} + + public syncWithCanvas(canvasSpec: V1CanvasSpec) { + const pinnedFilters = canvasSpec.pinnedFilters + ? Object.fromEntries( + canvasSpec.pinnedFilters.map((filter) => [filter, true]), + ) + : {}; + this.pinnedFilters = { ...pinnedFilters }; + this.specPinnedFilters = { ...pinnedFilters }; + + const requiredFilters = canvasSpec.requiredFilters + ? Object.fromEntries( + canvasSpec.requiredFilters.map((filter) => [filter, true]), + ) + : {}; + this.requiredFilters = { ...requiredFilters }; + this.specRequiredFilters = { ...requiredFilters }; + } + + public togglePinnedFilter(filter: string) { + this.pinnedFilters[filter] = !this.pinnedFilters[filter]; + } + + public toggleRequiredFilter(filter: string) { + this.requiredFilters[filter] = !this.requiredFilters[filter]; + } +} 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 2d3569e039d4..1882c990a831 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -21,7 +21,7 @@ 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; -import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); 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 487d75c88924..5d0fbe946c9f 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -33,7 +33,9 @@ import { contextColWidthDefaults, type ContextColWidths, } from "../leaderboard-context-column"; -import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +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"; export type StateManagers = { runtimeClient: RuntimeClient; @@ -193,6 +195,9 @@ export function createStateManagers({ }), contextColumnWidths, defaultExploreState, - expressionFilterManager: new ExpressionFilterManager(), + expressionFilterManager: new ExpressionFilterManager( + new MetricsViewsProvider(runtimeClient, [metricsViewName]), + new YAMLConfigProvider(), + ), }; } 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..6eade417e21c --- /dev/null +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -0,0 +1,224 @@ +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"; + +/** + * 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>({}); + /** Max queryable time range in milliseconds per metrics view name. Zero when unrestricted. */ + public maxQueryTimeRangeMillis = $state>({}); + + /** + * 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> + >({}); + /** Dimension spec per metrics view, keyed by dimension name (or column when unnamed). */ + public dimensionSpecs = $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 cleanup: () => void; + + private readonly timeRangeUnsubs = new Map void>(); + + public constructor( + public readonly runtimeClient: RuntimeClient, + public readonly metricsViewNames: string[], + ) { + const allResourcesQuery = createRuntimeServiceListResources( + runtimeClient, + {}, + ); + const allResourcesUnsub = allResourcesQuery.subscribe((allResourcesResp) => + this.processResources(allResourcesResp.data?.resources ?? []), + ); + + 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(); + }; + } + + private processResources(resources: V1Resource[]) { + 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 spec = resources.find( + (resource) => resource.meta?.name?.name === metricsViewName, + )?.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 }, + ); + 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/scheduled-reports/BaseScheduledReportForm.svelte b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte index 369b82748441..e8c8bf6f87b2 100644 --- a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte +++ b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte @@ -23,13 +23,14 @@ 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 "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; + import type { ExpressionFilterManager } from "../dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; export let formId: string; export let data: Readable; export let errors: SuperFormErrors; export let submit: () => void; export let enhance; + export let metricsViewName: string; export let exploreName: string; export let filters: ExpressionFilterManager; export let timeControls: TimeControls; @@ -142,7 +143,13 @@ id="filters" capitalize={false} /> - +
- invalidationForMetricsViewData(query, filters.metricsViewName), + invalidationForMetricsViewData(query, metricsViewName), }); const { interval, grain } = await deriveInterval( name, runtimeClient, - filters.metricsViewName, + metricsViewName, $selectedTimezone, timeDimension, ); @@ -272,6 +272,7 @@ Date: Thu, 30 Jul 2026 18:15:45 +0530 Subject: [PATCH 07/21] Support multiple metrics views --- .../src/features/alerts/AlertForm.svelte | 2 +- .../src/features/alerts/alert-preview-data.ts | 8 +- .../alerts/criteria-tab/AlertPreview.svelte | 2 +- .../alerts/data-tab/DataPreview.svelte | 2 +- web-common/src/features/alerts/form-utils.ts | 2 +- .../dimension-table/DimensionHeader.svelte | 2 +- .../dimension-table/DimensionTable.svelte | 2 +- .../dashboards/filters/Filters.svelte | 8 +- .../filters/manager/DimensionFilter.svelte | 187 +++++++----- .../manager/DimensionFilterManager.svelte.ts | 53 ++-- .../ExpressionFilterManager.svelte.spec.ts | 287 ------------------ .../manager/ExpressionFilterManager.svelte.ts | 284 +++++++++-------- .../filters/manager/ExpressionFilters.svelte | 20 +- .../filters/manager/MeasureFilter.svelte | 2 +- .../manager/MeasureFilterManager.svelte.ts | 41 +-- .../filters/manager/queries.svelte.ts | 243 +++++++++------ .../leaderboard/LeaderboardDisplay.svelte | 2 +- .../providers/YAMLConfigProvider.svelte.ts | 2 +- .../state-managers/actions/filters.ts | 16 +- .../time-dimension-details/TDDHeader.svelte | 2 +- .../TimeDimensionDisplay.svelte | 8 +- .../MetricsTimeSeriesCharts.svelte | 2 +- .../ScheduledReportDialog.svelte | 2 +- .../svelte-query/reactive-queries.svelte.ts | 63 ++++ 24 files changed, 565 insertions(+), 677 deletions(-) delete mode 100644 web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.spec.ts create mode 100644 web-common/src/lib/svelte-query/reactive-queries.svelte.ts diff --git a/web-common/src/features/alerts/AlertForm.svelte b/web-common/src/features/alerts/AlertForm.svelte index 2ff658deca6e..bdcbc6248f8e 100644 --- a/web-common/src/features/alerts/AlertForm.svelte +++ b/web-common/src/features/alerts/AlertForm.svelte @@ -185,7 +185,7 @@ queryArgsJson: JSON.stringify( getAlertQueryArgsFromFormValues( values, - filters.expr, + filters.exprByMetricsView[metricsViewName], timeControls.toState(), exploreSpec, ), diff --git a/web-common/src/features/alerts/alert-preview-data.ts b/web-common/src/features/alerts/alert-preview-data.ts index d6f0aa79bdfe..ea58f8c1fc75 100644 --- a/web-common/src/features/alerts/alert-preview-data.ts +++ b/web-common/src/features/alerts/alert-preview-data.ts @@ -11,10 +11,6 @@ import { ComparisonPercentOfTotal, } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts"; -import type { - Filters, - FiltersState, -} from "@rilldata/web-common/features/dashboards/stores/Filters.ts"; import type { TimeControls, TimeControlState, @@ -47,7 +43,7 @@ export function getAlertPreviewData( client: RuntimeClient, queryClient: QueryClient, formValues: AlertFormValues, - expr: V1Expression, + expr: V1Expression | undefined, timeControls: TimeControls, ): CreateQueryResult { return derived( @@ -77,7 +73,7 @@ export function getAlertPreviewData( function getAlertPreviewQueryRequest( formValues: AlertFormValues, - expr: V1Expression, + expr: V1Expression | undefined, timeControlArgs: TimeControlState, exploreSpec: V1ExploreSpec, ): V1MetricsViewAggregationRequest { diff --git a/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte b/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte index 70cd9442169e..0d8bc49e956b 100644 --- a/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte +++ b/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte @@ -30,7 +30,7 @@ runtimeClient, queryClient, formValues, - filters.expr, + filters.exprByMetricsView[formValues.metricsViewName], timeControls, ), ); diff --git a/web-common/src/features/alerts/data-tab/DataPreview.svelte b/web-common/src/features/alerts/data-tab/DataPreview.svelte index 4520e4a380bf..369f4144777a 100644 --- a/web-common/src/features/alerts/data-tab/DataPreview.svelte +++ b/web-common/src/features/alerts/data-tab/DataPreview.svelte @@ -32,7 +32,7 @@ ...formValues, criteria: [], }, - filters.expr, + filters.exprByMetricsView[formValues.metricsViewName], timeControls, ), ); diff --git a/web-common/src/features/alerts/form-utils.ts b/web-common/src/features/alerts/form-utils.ts index d167e59616ff..1e32bd12f50c 100644 --- a/web-common/src/features/alerts/form-utils.ts +++ b/web-common/src/features/alerts/form-utils.ts @@ -47,7 +47,7 @@ export type AlertFormValues = { export function getAlertQueryArgsFromFormValues( formValues: AlertFormValues, - expr: V1Expression, + expr: V1Expression | undefined, timeControlArgs: TimeControlState, exploreSpec: V1ExploreSpec, ): V1MetricsViewAggregationRequest { diff --git a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte index d9d5266ac715..b648dc3a9881 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte @@ -57,7 +57,7 @@ let exploreHasTimeDimension = $derived(!!$timeRangeSummaryStore.data); let excludeMode = $derived( - expressionFilterManager.dimensionFilterManagers.find( + expressionFilterManager.filterManagers.dimensions.find( (dfm) => dfm.name === dimensionName, )?.exclude ?? false, ); diff --git a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte index 6e76a01f64a3..a4bd98911ced 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte @@ -61,7 +61,7 @@ TableCells – the cell contents. } = getStateManagers(); let excludeMode = $derived( - expressionFilterManager.dimensionFilterManagers.find( + expressionFilterManager.filterManagers.dimensions.find( (dfm) => dfm.name === dimensionName, )?.exclude ?? false, ); diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index e747f0671bf6..b8fd361b2717 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -40,7 +40,6 @@ import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import ExpressionFilters from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilters.svelte"; - import { getMapFromArray } from "@rilldata/web-common/lib/arrayUtils.ts"; import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; const { rillTime } = featureFlags; @@ -63,8 +62,7 @@ filters: { setFilter }, }, selectors: { - measures: { allMeasures }, - dimensions: { allDimensions, timeDimensions }, + dimensions: { timeDimensions }, pivot: { showPivot }, charts: { canPanLeft, canPanRight, getNewPanRange }, }, @@ -116,8 +114,8 @@ $effect(() => setFilter( - expressionFilterManager.expr, - expressionFilterManager.dimensionFilterManagers + expressionFilterManager.exprByMetricsView[metricsViewName], + expressionFilterManager.filterManagers.dimensions .filter((dfm) => dfm.expr && dfm.mode === DimensionFilterMode.InList) .map((dfm) => dfm.name), ), diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte index 77e147a8ac52..5284ec040b20 100644 --- a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte @@ -26,8 +26,8 @@ import RequiredButton from "../RequiredButton.svelte"; import { DimensionFilterManager } from "./DimensionFilterManager.svelte.ts"; import { - getAllSearchResultsCount, - getDimensionSearchQuery, + createDimensionSearchCountQuery, + createDimensionSearchQuery, } from "@rilldata/web-common/features/dashboards/filters/manager/queries.svelte.ts"; import type { ExpressionFilterManager } from "./ExpressionFilterManager.svelte.ts"; import { onMount } from "svelte"; @@ -65,7 +65,6 @@ let open = $state(false); let curSearchText = $state(""); - let inListTooLong = $state(false); const client = useRuntimeClient(); @@ -83,7 +82,21 @@ // svelte-ignore state_referenced_locally let curRequired = $state(required); - $effect(() => checkSearchText(curSearchText)); + // Only InList mode parses the input as a list of values; the other modes treat it as search text, + // so this is read behind a mode check everywhere it is used. + let inListValues = $derived.by(() => { + const values = splitDimensionSearchText(curSearchText); + if (values.length <= 1) return curSearchText === "" ? [] : values; + + // Include both the applied values and the ones in the input so the below-fold query can find + // applied values that might not be in the top 250. + return [...new Set([...dimensionManager.selectedValues, ...values])]; + }); + let inListTooLong = $derived( + proxyDimensionManager.mode === DimensionFilterMode.InList && + inListValues.length > 0 && + isUrlTooLongAfterInListFilter(inListValues), + ); let enableSearchQuery = $derived( Boolean(timeControlsReady && open) && @@ -91,39 +104,54 @@ (proxyDimensionManager.mode === DimensionFilterMode.Contains && curSearchText.length > 0) || (proxyDimensionManager.mode === DimensionFilterMode.InList && - proxyDimensionManager.selectedValues.length > 0)), + inListValues.length > 0)), ); - let searchResultsQuery = $derived( - getDimensionSearchQuery(client, manager, dimensionManager, { - mode: proxyDimensionManager.mode, - values: proxyDimensionManager.selectedValues, - searchText: curSearchText, - timeStart, - timeEnd, - timeDimension, - enabled: enableSearchQuery, - }), - ); - let { - data: searchResults, - error: errorFromSearchResults, - isFetching: isFetchingFromSearchResults, - } = $derived($searchResultsQuery); - let correctedSearchResults = $derived(enableSearchQuery ? searchResults : []); + // The queries are created once and follow the dropdown through the args getter, so changing the + // mode or the search text updates them instead of replacing them. + const searchResultsQuery = createDimensionSearchQuery(client, () => ({ + manager, + dimensionName: dimensionManager.name, + mode: proxyDimensionManager.mode, + values: inListValues, + searchText: curSearchText, + timeStart, + timeEnd, + timeDimension, + enabled: enableSearchQuery, + })); + // The query only returns the top 250 values, so applied values outside that window are merged back + // in: Select mode shows them as checked, InList mode lists them among the values it matched. + let correctedSearchResults = $derived.by(() => { + if (!enableSearchQuery) return []; + + const searchResults = searchResultsQuery.current.data; + const appliedValues = + proxyDimensionManager.mode === DimensionFilterMode.InList + ? inListValues + : proxyDimensionManager.mode === DimensionFilterMode.Select + ? dimensionManager.selectedValues + : []; + if (!appliedValues.length) return searchResults; + + return [...new Set([...searchResults, ...appliedValues])]; + }); let enableSearchCountQuery = $derived( Boolean(timeControlsReady) && ((proxyDimensionManager.mode === DimensionFilterMode.Contains && curSearchText.length > 0) || (proxyDimensionManager.mode === DimensionFilterMode.InList && - proxyDimensionManager.selectedValues.length > 0)), + inListValues.length > 0)), ); - let allSearchResultsCountQuery = $derived( - getAllSearchResultsCount(client, manager, dimensionManager, { + const allSearchResultsCountQuery = createDimensionSearchCountQuery( + client, + () => ({ + manager, + dimensionName: dimensionManager.name, mode: proxyDimensionManager.mode, - values: proxyDimensionManager.selectedValues, + values: inListValues, searchText: curSearchText, timeStart, timeEnd, @@ -131,16 +159,12 @@ enabled: enableSearchCountQuery, }), ); - let { - data: allSearchResultsCount, - error: errorFromAllSearchResultsCount, - isFetching: isFetchingFromAllSearchResultsCount, - } = $derived($allSearchResultsCountQuery); + let allSearchResultsCount = $derived(allSearchResultsCountQuery.current.data); let searchResultCountText = $derived( enableSearchCountQuery ? proxyDimensionManager.mode === DimensionFilterMode.Contains ? `${allSearchResultsCount} results` - : `${allSearchResultsCount} of ${proxyDimensionManager.selectedValues.length} matched` + : `${allSearchResultsCount} of ${inListValues.length} matched` : "0 results", ); @@ -149,10 +173,12 @@ ); let error = $derived( - errorFromSearchResults ?? errorFromAllSearchResultsCount, + searchResultsQuery.current.error ?? + allSearchResultsCountQuery.current.error, ); let isFetching = $derived( - isFetchingFromSearchResults ?? isFetchingFromAllSearchResultsCount, + searchResultsQuery.current.isFetching || + allSearchResultsCountQuery.current.isFetching, ); let showExtraInfo = $derived( @@ -163,14 +189,14 @@ getEffectiveSelectedValues( proxyDimensionManager.mode, proxyDimensionManager.selectedValues, - correctedSearchResults ?? [], + correctedSearchResults, dimensionManager.selectedValues, ), ); let allSelected = $derived( Boolean( effectiveSelectedValues.length && - correctedSearchResults?.length === effectiveSelectedValues.length, + correctedSearchResults.length === effectiveSelectedValues.length, ), ); @@ -187,44 +213,20 @@ let { checkedItems, uncheckedItems } = $derived( getItemLists( proxyDimensionManager.mode, - correctedSearchResults ?? [], + correctedSearchResults, dimensionManager.selectedValues, curSearchText, ), ); - function checkSearchText(inputText: string) { - inListTooLong = false; - - // Only InList mode parses bulk values. Other modes treat input as search text. - if (proxyDimensionManager.mode !== DimensionFilterMode.InList) return; - - const values = splitDimensionSearchText(inputText); - - if (values.length <= 1) { - proxyDimensionManager.selectedValues = inputText === "" ? [] : values; - return; - } - - // Include both existing selected values and new search values so the - // below-fold query can find existing selected values that might not be - // in the top 250. - proxyDimensionManager.selectedValues = [ - ...new Set([...proxyDimensionManager.selectedValues, ...values]), - ]; - inListTooLong = isUrlTooLongAfterInListFilter( - proxyDimensionManager.selectedValues, - ); - } - function handleModeChange(newMode: DimensionFilterMode) { - curSearchText = ""; switch (newMode) { case DimensionFilterMode.Select: proxyDimensionManager.setSelectedValues( [...proxyDimensionManager.selectedValues], proxyDimensionManager.exclude, ); + curSearchText = ""; break; case DimensionFilterMode.InList: @@ -232,38 +234,49 @@ [...proxyDimensionManager.selectedValues], proxyDimensionManager.exclude, ); + // The values carried over stay editable as text. + curSearchText = mergeDimensionSearchValues( + proxyDimensionManager.selectedValues, + ); break; case DimensionFilterMode.Contains: proxyDimensionManager.setContainsText( - curSearchText, + "", proxyDimensionManager.exclude, ); + curSearchText = ""; break; } - checkSearchText(curSearchText); } function handleOpenChange(open: boolean) { if (open) { - curSearchText = - dimensionManager.mode === DimensionFilterMode.InList - ? mergeDimensionSearchValues(dimensionManager.selectedValues) - : dimensionManager.inputText; + resetSearchText(); curPinned = pinned; curRequired = required; } else { persistPinnedAndRequired(); - // Apply proxy changes for Select mode when dropdown closes + // Select mode applies when the dropdown closes. The other modes only apply through the Apply + // button, so drop whatever they staged. if (proxyDimensionManager.mode === DimensionFilterMode.Select) { - dimensionManager.expr = proxyDimensionManager.expr; + dimensionManager.apply(proxyDimensionManager); } else { - proxyDimensionManager = dimensionManager.clone(); + proxyDimensionManager.apply(dimensionManager); + resetSearchText(); } } } + /** Resets the input to the applied filter: the list of values in InList mode, the search text otherwise. */ + function resetSearchText() { + curSearchText = + dimensionManager.mode === DimensionFilterMode.InList + ? mergeDimensionSearchValues(dimensionManager.selectedValues) + : dimensionManager.inputText; + } + function handleToggleExcludeMode() { proxyDimensionManager.toggleExclude(); } @@ -281,13 +294,23 @@ function onApply(close = true) { if (disableApplyButton) return; persistPinnedAndRequired(); - if (proxyDimensionManager.mode === DimensionFilterMode.Contains) { - proxyDimensionManager.setContainsText( - curSearchText, - proxyDimensionManager.exclude, - ); + // Select mode stages straight onto the proxy; the other two stage in the input. + switch (proxyDimensionManager.mode) { + case DimensionFilterMode.InList: + proxyDimensionManager.setInList( + [...inListValues], + proxyDimensionManager.exclude, + ); + break; + + case DimensionFilterMode.Contains: + proxyDimensionManager.setContainsText( + curSearchText, + proxyDimensionManager.exclude, + ); + break; } - dimensionManager.expr = proxyDimensionManager.expr; + dimensionManager.apply(proxyDimensionManager); if (close) open = false; } @@ -311,13 +334,13 @@ openOnMount && !dimensionManager.selectedValues?.length && !dimensionManager.inputText; - curSearchText = dimensionManager.inputText; + resetSearchText(); }); { - if (e.key === "Enter") onApply(); + if (open && e.key === "Enter") onApply(); }} /> @@ -357,10 +380,10 @@ show={1} {smallChip} values={proxyDimensionManager.mode === DimensionFilterMode.InList - ? proxyDimensionManager.selectedValues + ? inListValues : effectiveSelectedValues} matchedCount={allSearchResultsCount} - loading={isFetchingFromAllSearchResultsCount} + loading={allSearchResultsCountQuery.current.isFetching} search={proxyDimensionManager.mode === DimensionFilterMode.Contains ? curSearchText : undefined} @@ -466,7 +489,7 @@
List is too long. Please remove some values.
- {:else if correctedSearchResults} + {:else} ([]); + public inputText = $state(""); + public exclude = $state(false); private oldMode: DimensionFilterMode; public constructor( public readonly name: string, public readonly label: string, - public readonly dimensions: Record, 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 reconcile(expr: V1Expression, inList: string[]) { let initMode: DimensionFilterMode = DimensionFilterMode.Select; let initSelectedValues: string[] = []; let initInputText: string = ""; let initExclude: boolean = false; - const op = initExpr.cond?.op; + const op = expr.cond?.op; if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { - initMode = isInList + initMode = inList.includes(this.name) ? DimensionFilterMode.InList : DimensionFilterMode.Select; - initSelectedValues = getValuesInExpression(initExpr); + initSelectedValues = getValuesInExpression(expr); initExclude = op === V1Operation.OPERATION_NIN; } else if ( op === V1Operation.OPERATION_LIKE || @@ -48,29 +53,37 @@ export class DimensionFilterManager { ) { initMode = DimensionFilterMode.Contains; initInputText = sanitizeSearchText( - initExpr.cond?.exprs?.[1]?.val?.toString?.() ?? "", + expr.cond?.exprs?.[1]?.val?.toString?.() ?? "", ); initExclude = op === V1Operation.OPERATION_NLIKE; } - this.mode = $state(initMode); + this.mode = initMode; this.oldMode = initMode; - this.selectedValues = $state(initSelectedValues); - this.inputText = $state(initInputText); - this.exclude = $state(initExclude); - this.commit(); + this.selectedValues = initSelectedValues; + this.inputText = initInputText; + this.exclude = initExclude; + this.commit(false); } public clone() { return new DimensionFilterManager( this.name, this.label, - this.dimensions, 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; @@ -138,10 +151,14 @@ export class DimensionFilterManager { this.commit(); } - public commit() { + public commit(notify: boolean = true) { switch (this.mode) { case DimensionFilterMode.Select: - if (this.oldMode !== DimensionFilterMode.Select) { + if ( + this.oldMode !== DimensionFilterMode.Select && + !this.ephemeral && + notify + ) { eventBus.emit("notification", { message: "Converted filter type to Select", link: { diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.spec.ts b/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.spec.ts deleted file mode 100644 index f1bc22611b48..000000000000 --- a/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ExpressionFilterManager } from "./ExpressionFilterManager.svelte.ts"; -import { DimensionFilterManager } from "./DimensionFilterManager.svelte.ts"; -import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; -import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; -import type { - MetricsViewSpecDimension, - MetricsViewSpecMeasure, -} from "@rilldata/web-common/runtime-client"; -import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; - -const MetricsViewName = "mv"; - -/** Stands in for the specs a MetricsViewsProvider exposes, so they can arrive mid-test. */ -class TestMetricsViewsProvider { - public dimensionSpecs = $state< - Record> - >({}); - public measureSpecs = $state< - Record> - >({}); - - public loadSpecs() { - this.dimensionSpecs = { - country: { [MetricsViewName]: { name: "country" } }, - state: { [MetricsViewName]: { name: "state" } }, - }; - this.measureSpecs = { - revenue: { [MetricsViewName]: { name: "revenue" } }, - }; - } -} - -function createManager() { - const metricsViewsProvider = new TestMetricsViewsProvider(); - const yamlConfigProvider = new YAMLConfigProvider(true); - const filterManager = new ExpressionFilterManager( - metricsViewsProvider as unknown as MetricsViewsProvider, - yamlConfigProvider, - ); - return { filterManager, metricsViewsProvider, yamlConfigProvider }; -} - -function chipNames(filterManager: ExpressionFilterManager) { - return [ - ...filterManager.dimensionFilterManagers.map((dfm) => dfm.name), - ...filterManager.measureFilterManagers.map((mfm) => mfm.name), - ]; -} - -describe("ExpressionFilterManager", () => { - it("builds dimension and measure managers from the param", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider } = createManager(); - metricsViewsProvider.loadSpecs(); - - filterManager.setExprParam( - "country IN ('US','IN') AND state having (revenue GTE 10)", - ); - - expect( - filterManager.dimensionFilterManagers.map((dfm) => [ - dfm.name, - dfm.selectedValues, - ]), - ).toEqual([["country", ["US", "IN"]]]); - expect( - filterManager.measureFilterManagers.map((mfm) => [ - mfm.name, - mfm.dimension, - mfm.value1, - ]), - ).toEqual([["revenue", "state", "10"]]); - expect(filterManager.isComplexFilter).toBe(false); - }); - cleanup(); - }); - - it("rebuilds once the metrics view specs arrive", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider } = createManager(); - - // The param is set before ListResources resolves, so nothing can be resolved yet. - filterManager.setExprParam("country IN ('US')"); - expect(chipNames(filterManager)).toEqual([]); - - metricsViewsProvider.loadSpecs(); - expect(chipNames(filterManager)).toEqual(["country"]); - }); - cleanup(); - }); - - it("leads with required filters, then pinned ones, and keeps a chip for both without a value", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider, yamlConfigProvider } = - createManager(); - metricsViewsProvider.loadSpecs(); - yamlConfigProvider.togglePinnedFilter("country"); - yamlConfigProvider.toggleRequiredFilter("state"); - - filterManager.setExprParam("country IN ('US')"); - - // "state" is required and has no value, so it gets an empty chip ahead of the pinned one. - expect(chipNames(filterManager)).toEqual(["state", "country"]); - const [stateManager] = filterManager.dimensionFilterManagers; - expect(stateManager.expr).toBeUndefined(); - // An empty chip contributes nothing to the expression. - expect(convertExpressionToFilterParam(filterManager.expr)).toEqual( - "country IN ('US')", - ); - }); - cleanup(); - }); - - it("adds and removes chips when the pinned config changes", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider, yamlConfigProvider } = - createManager(); - metricsViewsProvider.loadSpecs(); - filterManager.setExprParam("country IN ('US')"); - - yamlConfigProvider.togglePinnedFilter("revenue"); - expect(chipNames(filterManager)).toEqual(["country", "revenue"]); - - yamlConfigProvider.togglePinnedFilter("revenue"); - expect(chipNames(filterManager)).toEqual(["country"]); - }); - cleanup(); - }); - - it("keeps a value applied to a required chip while the pinned config changes with it", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider, yamlConfigProvider } = - createManager(); - metricsViewsProvider.loadSpecs(); - yamlConfigProvider.toggleRequiredFilter("country"); - filterManager.setExprParam(""); - - const [countryManager] = filterManager.dimensionFilterManagers; - // The filter chip persists pinned/required together with the value it was given. - yamlConfigProvider.togglePinnedFilter("country"); - countryManager.setSelectedValues(["US"], false); - - expect(convertExpressionToFilterParam(filterManager.expr)).toEqual( - "country IN ('US')", - ); - }); - cleanup(); - }); - - it("does not restore a stale value after a required filter is cleared", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider, yamlConfigProvider } = - createManager(); - metricsViewsProvider.loadSpecs(); - yamlConfigProvider.toggleRequiredFilter("country"); - filterManager.setExprParam(""); - - filterManager.dimensionFilterManagers[0].setSelectedValues(["US"], false); - // The value reaches the url, so the param takes over from here. - filterManager.setExprParam( - convertExpressionToFilterParam(filterManager.expr), - ); - expect(filterManager.dimensionFilterManagers[0].selectedValues).toEqual([ - "US", - ]); - - filterManager.dimensionFilterManagers[0].clear(); - filterManager.setExprParam( - convertExpressionToFilterParam(filterManager.expr), - ); - - // The chip stays because the filter is required, but without its old value. - expect(chipNames(filterManager)).toEqual(["country"]); - expect(filterManager.dimensionFilterManagers[0].selectedValues).toEqual( - [], - ); - }); - cleanup(); - }); - - it("keeps a temporary filter until the param covers it", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider } = createManager(); - metricsViewsProvider.loadSpecs(); - filterManager.setExprParam("country IN ('US')"); - - filterManager.addTemporaryFilter("state"); - expect(chipNames(filterManager)).toEqual(["country", "state"]); - expect(filterManager.temporaryFilterName).toEqual("state"); - - // An unrelated url change keeps the chip, since the param still does not cover it. - filterManager.setExprParam("country IN ('US')"); - expect(chipNames(filterManager)).toEqual(["country", "state"]); - - filterManager.setExprParam("country IN ('US') AND state IN ('CA')"); - expect(chipNames(filterManager)).toEqual(["country", "state"]); - expect(filterManager.temporaryFilterName).toBeUndefined(); - }); - cleanup(); - }); - - it("keeps a chip for a filter created by a dimension filter action", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider } = createManager(); - metricsViewsProvider.loadSpecs(); - filterManager.setExprParam("country IN ('US')"); - - filterManager.dimensionFilterAction("state", (dimensionFilterManager) => - dimensionFilterManager.toggleValue("CA", false), - ); - - expect(chipNames(filterManager)).toEqual(["country", "state"]); - expect(convertExpressionToFilterParam(filterManager.expr)).toEqual( - "country IN ('US') AND state IN ('CA')", - ); - }); - cleanup(); - }); - - it("routes a dimension filter action to the manager the param already has", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider } = createManager(); - metricsViewsProvider.loadSpecs(); - filterManager.setExprParam("country IN ('US')"); - - filterManager.dimensionFilterAction("country", (dimensionFilterManager) => - dimensionFilterManager.toggleValue("IN", false), - ); - - expect(chipNames(filterManager)).toEqual(["country"]); - expect(convertExpressionToFilterParam(filterManager.expr)).toEqual( - "country IN ('US','IN')", - ); - }); - cleanup(); - }); - - it("clear drops every filter but keeps the required and pinned chips", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider, yamlConfigProvider } = - createManager(); - metricsViewsProvider.loadSpecs(); - yamlConfigProvider.togglePinnedFilter("state"); - filterManager.setExprParam("country IN ('US') AND state IN ('CA')"); - filterManager.addTemporaryFilter("revenue"); - - filterManager.clear(); - - expect(chipNames(filterManager)).toEqual(["state"]); - expect(filterManager.dimensionFilterManagers[0].expr).toBeUndefined(); - expect(filterManager.temporaryFilterName).toBeUndefined(); - }); - cleanup(); - }); - - it("has no chips for a filter it cannot represent", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider } = createManager(); - metricsViewsProvider.loadSpecs(); - - filterManager.setExprParam("country IN ('US') OR state IN ('CA')"); - - expect(filterManager.isComplexFilter).toBe(true); - expect(chipNames(filterManager)).toEqual([]); - }); - cleanup(); - }); - - it("skips filters no metrics view defines", () => { - const cleanup = $effect.root(() => { - const { filterManager, metricsViewsProvider, yamlConfigProvider } = - createManager(); - metricsViewsProvider.loadSpecs(); - yamlConfigProvider.togglePinnedFilter("unknown_dimension"); - - filterManager.setExprParam("country IN ('US') AND removed IN ('CA')"); - - expect(chipNames(filterManager)).toEqual(["country"]); - expect( - filterManager.dimensionFilterManagers[0] instanceof - DimensionFilterManager, - ).toBe(true); - }); - cleanup(); - }); -}); diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts index a54853a2584f..a46619dff1af 100644 --- a/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts @@ -17,7 +17,7 @@ import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/f import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; -type FilterManager = DimensionFilterManager | MeasureFilterManager; +type FilterManager = MeasureFilterManager | DimensionFilterManager; /** * Filter managers for the chips in a filter bar. @@ -32,35 +32,21 @@ export class ExpressionFilterManager { public exprParam: string = $state(""); /** Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. */ public temporaryFilterName: string | undefined = $state(undefined); - /** - * Names of the filters that get a chip even though the param does not cover them and they are - * neither required nor pinned: the one added from the filter menu and the ones created by a chart - * interaction. - */ - private localFilterNames: string[] = $state([]); - public dimensionFilterManagers: DimensionFilterManager[]; - public measureFilterManagers: MeasureFilterManager[]; /** True when the param cannot be represented as chips. */ public isComplexFilter: boolean; public expr: V1Expression; + public exprByMetricsView: Record; - private parsedParam: ReturnType; - /** Managers for the filters in the param, keyed by name and in param order. */ - private paramFilterManagers: { - dimension: Map; - measure: Map; - }; - private filterManagers: { - dimension: DimensionFilterManager[]; - measure: MeasureFilterManager[]; + public filterManagers: { + measures: MeasureFilterManager[]; + dimensions: DimensionFilterManager[]; }; - /** - * Managers for the filters the param does not cover. They are kept across rebuilds so that a - * rebuild cannot drop an edit that has not reached the param yet; `setExprParam` drops the ones - * the param has caught up with. - */ - private readonly outOfParamManagers = new Map(); + + private readonly parsedParam: ReturnType< + typeof convertFilterParamToExpression + >; + private readonly filterManagersMap: Record; public constructor( public readonly metricsViewsProvider: MetricsViewsProvider, @@ -85,88 +71,102 @@ export class ExpressionFilterManager { ? isExpressionUnsupported(this.parsedParam.expr) : false, ); - this.paramFilterManagers = $derived.by(() => - this.buildParamFilterManagers(), + this.filterManagers = $derived.by(() => + this.buildFilterManagers(this.buildParamFilterManagers()), + ); + this.filterManagersMap = $derived.by(() => + Object.fromEntries([ + ...this.filterManagers.dimensions.map( + (dfm) => [dfm.name, dfm] as [string, DimensionFilterManager], + ), + ...this.filterManagers.measures.map( + (mfm) => [mfm.name, mfm] as [string, MeasureFilterManager], + ), + ]), ); - this.filterManagers = $derived.by(() => this.buildFilterManagers()); - this.dimensionFilterManagers = $derived(this.filterManagers.dimension); - this.measureFilterManagers = $derived(this.filterManagers.measure); this.expr = $derived.by(() => { - const dimExprs = this.dimensionFilterManagers + const dimExprs = this.filterManagers.dimensions .filter((dfm) => !!dfm.expr) .map((d) => d.expr as V1Expression); - const mesExprs = this.measureFilterManagers + const mesExprs = this.filterManagers.measures .filter((mfm) => !!mfm.expr) .map((m) => m.expr as V1Expression); return createAndExpression(dimExprs.concat(mesExprs)); }); + this.exprByMetricsView = $derived.by(() => + this.buildExpressionsByMetricsView(), + ); } public setExprParam(exprParam: string) { + if (exprParam === this.exprParam) return; + console.log("new filter", exprParam); + this.temporaryFilterName = undefined; this.exprParam = exprParam; - - // Once the param covers a filter its param manager takes over, so drop the manager that was - // kept for it. Keeping it would restore the value it used to have when the filter is cleared. - for (const name of this.outOfParamManagers.keys()) { - if (this.isInParam(name)) this.outOfParamManagers.delete(name); - } - - const localFilterNames = this.localFilterNames.filter( - (name) => !this.isInParam(name), - ); - // Assigning unconditionally would rebuild every manager on any url change. - if (localFilterNames.length !== this.localFilterNames.length) { - this.localFilterNames = localFilterNames; - } - - if (this.temporaryFilterName && this.isInParam(this.temporaryFilterName)) { - this.temporaryFilterName = undefined; - } } /** Adds a chip for a filter that has no value yet. The chip opens as soon as it is rendered. */ - public addTemporaryFilter(name: string) { - if (!this.getFilterManager(name)) return; + public addNewFilter(name: string) { + if ( + !this.metricsViewsProvider.dimensionSpecs[name] && + !this.metricsViewsProvider.measureSpecs[name] + ) { + return; + } + console.log("new filter", name); this.temporaryFilterName = name; - this.addLocalFilterName(name); } public dimensionFilterAction( name: string, callback: (dimensionFilterManager: DimensionFilterManager) => any, ) { - const dimensionFilterManager = this.getFilterManager(name); + const dimensionFilterManager = + this.filterManagersMap[name] ?? this.createDimensionFilterManager(name); if (!(dimensionFilterManager instanceof DimensionFilterManager)) return; const ret = callback(dimensionFilterManager); - // A filter this action created needs a chip until the param catches up with it. - if (dimensionFilterManager.expr) this.addLocalFilterName(name); + if (dimensionFilterManager.expr && !(name in this.filterManagersMap)) { + this.filterManagers = { + ...this.filterManagers, + dimensions: [...this.filterManagers.dimensions, dimensionFilterManager], + }; + } return ret; } public clear() { - this.exprParam = ""; - this.localFilterNames = []; this.temporaryFilterName = undefined; - this.outOfParamManagers.clear(); + this.exprParam = ""; } - public getOtherDimensionsFilter(name: string) { - const exprs = this.dimensionFilterManagers - .filter((dfm) => !!dfm.expr && dfm.name !== name) + /** + * Filters that apply to `metricsViewName`, other than the one on `name`. + * Used to narrow the values shown in a dimension filter by the rest of the filter bar. + */ + public getOtherDimensionsFilter(name: string, metricsViewName: string) { + const exprs = this.filterManagers.dimensions + .filter( + (dfm) => + !!dfm.expr && + dfm.name !== name && + !!this.metricsViewsProvider.dimensionSpecs[dfm.name]?.[ + metricsViewName + ], + ) .map((d) => d.expr as V1Expression); return exprs.length === 0 ? undefined : createAndExpression(exprs); } /** Managers for the filters in the param, keyed by name and in param order. */ private buildParamFilterManagers() { - const dimension = new Map(); - const measure = new Map(); + const measuresMap = new Map(); + const dimensionsMap = new Map(); const { expr, dimensionsWithInlistFilter } = this.parsedParam; - if (!expr || this.isComplexFilter) return { dimension, measure }; + if (!expr || this.isComplexFilter) return { measuresMap, dimensionsMap }; forEachIdentifier(expr, (e, ident) => { // Both filter types are keyed off a dimension: a dimension filter by the dimension it filters @@ -181,11 +181,10 @@ export class ExpressionFilterManager { const measureFilterManager = this.createMeasureFilterManager( measureName, - ident, firstValueExpr, ); if (measureFilterManager) - measure.set(measureName, measureFilterManager); + measuresMap.set(measureName, measureFilterManager); } else { const dimensionFilterManager = this.createDimensionFilterManager( ident, @@ -193,86 +192,86 @@ export class ExpressionFilterManager { dimensionsWithInlistFilter.includes(ident), ); if (dimensionFilterManager) - dimension.set(ident, dimensionFilterManager); + dimensionsMap.set(ident, dimensionFilterManager); } }); - return { dimension, measure }; + return { measuresMap, dimensionsMap }; } /** * Managers in chip order: required filters first, then pinned ones, then the rest of the param * filters in param order, and finally the filters the param does not cover yet. */ - private buildFilterManagers() { - const dimension: DimensionFilterManager[] = []; - const measure: MeasureFilterManager[] = []; + private buildFilterManagers({ + measuresMap, + dimensionsMap, + }: { + measuresMap: Map; + dimensionsMap: Map; + }) { + const dimensions: DimensionFilterManager[] = []; + const measures: MeasureFilterManager[] = []; const added = new Set(); const add = (name: string) => { if (added.has(name)) return; - const filterManager = this.getFilterManager(name); + const filterManager = + dimensionsMap.get(name) ?? + measuresMap.get(name) ?? + this.createDimensionFilterManager(name) ?? + this.createMeasureFilterManager(name); if (!filterManager) return; added.add(name); if (filterManager instanceof MeasureFilterManager) { - measure.push(filterManager); + measures.push(filterManager); } else { - dimension.push(filterManager); + dimensions.push(filterManager); } }; // Required and pinned filters lead the chips and have a chip even without a value. const { requiredFilters, pinnedFilters } = this.yamlConfigProvider; - const configuredFilters = new Set([ - ...Object.keys(requiredFilters), - ...Object.keys(pinnedFilters), - ]); - for (const name of configuredFilters) { - if (requiredFilters[name] || pinnedFilters[name]) add(name); - } - - this.paramFilterManagers.dimension.forEach((_, name) => add(name)); - this.paramFilterManagers.measure.forEach((_, name) => add(name)); - this.localFilterNames.forEach((name) => add(name)); - - return { dimension, measure }; + Object.keys(requiredFilters).forEach((name) => add(name)); + Object.keys(pinnedFilters).forEach((name) => add(name)); + + // Next comes dimensions from params + const shouldAddTempDimension = Boolean( + this.temporaryFilterName && + !dimensionsMap.has(this.temporaryFilterName) && + this.temporaryFilterName in this.metricsViewsProvider.dimensionSpecs, + ); + sortFilterIterator( + dimensionsMap.keys(), + shouldAddTempDimension ? this.temporaryFilterName : undefined, + ).forEach((name) => add(name)); + + // Next comes measures from params + const shouldAddTempMeasure = + this.temporaryFilterName && + !measuresMap.has(this.temporaryFilterName) && + this.temporaryFilterName in this.metricsViewsProvider.measureSpecs; + sortFilterIterator( + measuresMap.keys(), + shouldAddTempMeasure ? this.temporaryFilterName : undefined, + ).forEach((name) => add(name)); + + return { measures, dimensions }; } - /** - * Manager for a filter by name: the param one when the param covers the filter, else one that is - * kept until the param does. Returns undefined when no metrics view defines the filter. - */ - private getFilterManager(name: string): FilterManager | undefined { - const paramFilterManager = - this.paramFilterManagers.dimension.get(name) ?? - this.paramFilterManagers.measure.get(name); - if (paramFilterManager) return paramFilterManager; - - const outOfParamManager = this.outOfParamManagers.get(name); - if (outOfParamManager) return outOfParamManager; - - const filterManager = - this.createDimensionFilterManager(name) ?? - this.createMeasureFilterManager(name); - if (filterManager) this.outOfParamManagers.set(name, filterManager); - return filterManager; - } + private createMeasureFilterManager(name: string, initExpr?: V1Expression) { + const measureSpecs = this.metricsViewsProvider.measureSpecs[name]; + if (!measureSpecs) return undefined; - private isInParam(name: string) { - return ( - this.paramFilterManagers.dimension.has(name) || - this.paramFilterManagers.measure.has(name) + return new MeasureFilterManager( + name, + getMeasureDisplayName(Object.values(measureSpecs)[0]), + initExpr, ); } - private addLocalFilterName(name: string) { - if (this.isInParam(name) || this.localFilterNames.includes(name)) return; - this.localFilterNames = [...this.localFilterNames, name]; - } - - /** Returns undefined when no metrics view defines the dimension. */ private createDimensionFilterManager( name: string, initExpr?: V1Expression, @@ -284,27 +283,50 @@ export class ExpressionFilterManager { return new DimensionFilterManager( name, getDimensionDisplayName(Object.values(dimensionSpecs)[0]), - dimensionSpecs, initExpr, isInList, ); } - /** Returns undefined when no metrics view defines the measure. */ - private createMeasureFilterManager( - name: string, - initDimension?: string, - initExpr?: V1Expression, - ) { - const measureSpecs = this.metricsViewsProvider.measureSpecs[name]; - if (!measureSpecs) return undefined; + private buildExpressionsByMetricsView() { + const exprsByMetricsView = Object.fromEntries( + this.metricsViewsProvider.metricsViewNames.map((m) => [ + m, + [] as V1Expression[], + ]), + ); - return new MeasureFilterManager( - name, - getMeasureDisplayName(Object.values(measureSpecs)[0]), - measureSpecs, - initDimension, - initExpr, + this.filterManagers.dimensions.forEach((dfm) => { + if (!dfm.expr) return; + Object.keys( + this.metricsViewsProvider.dimensionSpecs[dfm.name] ?? {}, + ).forEach((mv) => { + exprsByMetricsView[mv]?.push(dfm.expr!); + }); + }); + + this.filterManagers.measures.forEach((mfm) => { + if (!mfm.expr) return; + Object.keys( + this.metricsViewsProvider.measureSpecs[mfm.name] ?? {}, + ).forEach((mv) => { + exprsByMetricsView[mv]?.push(mfm.expr!); + }); + }); + + return Object.fromEntries( + Object.entries(exprsByMetricsView) + .filter(([, exprs]) => exprs.length > 0) + .map(([mv, exprs]) => [mv, createAndExpression(exprs)]), ); } } + +function sortFilterIterator( + iterator: MapIterator | SetIterator, + tempName: string | undefined, +) { + const newArr = [...iterator]; + if (tempName) newArr.push(tempName); + return newArr.sort((a, b) => (a > b ? 1 : -1)); +} diff --git a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte index 5c8663aaa480..db1ea5d6f4bf 100644 --- a/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte @@ -50,23 +50,25 @@ ); let hasFilters = $derived( - expressionFilterManager.dimensionFilterManagers.length > 0 || - expressionFilterManager.measureFilterManagers.length > 0, + expressionFilterManager.filterManagers.dimensions.length > 0 || + expressionFilterManager.filterManagers.measures.length > 0, ); // Required and pinned filters have a chip even without a value, so there is nothing to clear // unless a chip actually holds a filter. let hasClearableFilters = $derived( - expressionFilterManager.dimensionFilterManagers.some((dfm) => !!dfm.expr) || - expressionFilterManager.measureFilterManagers.some((mfm) => !!mfm.expr), + expressionFilterManager.filterManagers.dimensions.some( + (dfm) => !!dfm.expr, + ) || + expressionFilterManager.filterManagers.measures.some((mfm) => !!mfm.expr), ); let measureHasFilter = $derived((name: string) => - expressionFilterManager.measureFilterManagers.some( + expressionFilterManager.filterManagers.measures.some( (mfm) => mfm.name === name, ), ); let dimensionHasFilter = $derived((name: string) => - expressionFilterManager.dimensionFilterManagers.some( + expressionFilterManager.filterManagers.dimensions.some( (dfm) => dfm.name === name, ), ); @@ -83,7 +85,7 @@ {m.dashboard_no_filters_selected()}
{:else} - {#each expressionFilterManager.dimensionFilterManagers as dimensionManager (dimensionManager.name)} + {#each expressionFilterManager.filterManagers.dimensions as dimensionManager (dimensionManager.name)} {/each} - {#each expressionFilterManager.measureFilterManagers as measureManager (measureManager.name)} + {#each expressionFilterManager.filterManagers.measures as measureManager (measureManager.name)} - expressionFilterManager.addTemporaryFilter(name)} + expressionFilterManager.addNewFilter(name)} /> diff --git a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte index fde6be148116..ab5c5631b3e2 100644 --- a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte +++ b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte @@ -58,7 +58,7 @@ function onApply(dimension: string, filter: MeasureFilterEntry) { persistPinnedAndRequired(); - measureManager.apply(dimension, filter); + measureManager.setMeasureFilter(dimension, filter); } function persistPinnedAndRequired() { diff --git a/web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts index 7dcb8cb4c111..96baaae06379 100644 --- a/web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/MeasureFilterManager.svelte.ts @@ -1,7 +1,4 @@ -import type { - MetricsViewSpecMeasure, - V1Expression, -} from "@rilldata/web-common/runtime-client"; +import type { V1Expression } from "@rilldata/web-common/runtime-client"; import { mapExprToMeasureFilter, mapMeasureFilterToExpr, @@ -19,35 +16,39 @@ import { export class MeasureFilterManager { public expr: V1Expression | undefined = $state(undefined); - public dimension: string; - public operation: MeasureFilterOperation; - public type: MeasureFilterType; - public value1: string; - public value2: string; + 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, - public readonly measures: Record, - initDimension: string = "", initExpr: V1Expression | undefined = undefined, ) { + this.reconcile(initExpr); + } + + public reconcile(expr: V1Expression | undefined) { + const dimension = expr?.cond?.exprs?.[0]?.ident; + const firstValueExpr = expr?.cond?.exprs?.[1]; + const unwrappedHavingFilter = removeWrapperAndOrExpression( - initExpr?.subquery?.having, + firstValueExpr?.subquery?.having, ); const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter); - this.dimension = $state(initDimension); - this.operation = $state( - mappedMeasureFilter?.operation ?? MeasureFilterOperation.LessThan, - ); - this.type = $state(mappedMeasureFilter?.type ?? MeasureFilterType.Value); - this.value1 = $state(mappedMeasureFilter?.value1 ?? ""); - this.value2 = $state(mappedMeasureFilter?.value2 ?? ""); + 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 apply(dimension: string, newFilter: MeasureFilterEntry) { + public setMeasureFilter(dimension: string, newFilter: MeasureFilterEntry) { this.dimension = dimension; this.operation = newFilter.operation; this.type = newFilter.type; diff --git a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts index 48c1b8d331f5..0d3c22254b46 100644 --- a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts +++ b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts @@ -1,7 +1,5 @@ import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; -import type { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/DimensionFilterManager.svelte.ts"; -import { createQuery } from "@tanstack/svelte-query"; import { getQueryServiceMetricsViewAggregationQueryOptions, V1BuiltinMeasure, @@ -13,8 +11,11 @@ import { 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[]; @@ -24,113 +25,157 @@ type DimensionSearchArgs = { enabled?: boolean; }; -export function getDimensionSearchQuery( +/** + * 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, - manager: ExpressionFilterManager, - dimensionManager: DimensionFilterManager, - { - mode, - searchText, - values, - timeStart, - timeEnd, - timeDimension, - enabled, - }: DimensionSearchArgs, + getArgs: () => DimensionSearchArgs, ) { - const dimensionName = dimensionManager.name; - const mvName = Object.keys(dimensionManager.dimensions)[0] ?? ""; + return createReactiveQueries( + () => { + const { + manager, + dimensionName, + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + } = getArgs(); - const otherDimsFilter = $derived.by(() => { - manager.expr; // Force rederivation when expr changes. - return manager.getOtherDimensionsFilter(dimensionManager.name); - }); - const where = getFilterForSearchArgs(dimensionManager.name, { - mode, - searchText, - values, - additionalFilter: otherDimsFilter, - }); - - const optionsStore = $derived( - getQueryServiceMetricsViewAggregationQueryOptions( - client, - { - metricsView: mvName, - dimensions: [{ name: dimensionName }], - timeRange: { start: timeStart, end: timeEnd, timeDimension }, - limit: "250", - offset: "0", - sort: [{ name: dimensionName }], - where, - }, - { - query: { - select: (resp) => - resp.data?.map((d) => d[dimensionName] as string) ?? [], - enabled, - }, - }, - ), + 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 ?? [])), + ], ); - return createQuery(optionsStore); } -export function getAllSearchResultsCount( +/** + * 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, - manager: ExpressionFilterManager, - dimensionManager: DimensionFilterManager, - { - mode, - searchText, - values, - timeStart, - timeEnd, - timeDimension, - enabled, - }: DimensionSearchArgs, + getArgs: () => DimensionSearchArgs, ) { - const dimensionName = dimensionManager.name; - const mvName = Object.keys(dimensionManager.dimensions)[0] ?? ""; - const countMeasureName = dimensionName + "__distinct_count"; + return createReactiveQueries( + () => { + const { + manager, + dimensionName, + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + } = getArgs(); + const countMeasureName = dimensionName + "__distinct_count"; - const otherDimsFilter = $derived.by(() => { - manager.expr; // Force rederivation when expr changes. - return manager.getOtherDimensionsFilter(dimensionManager.name); - }); - const where = getFilterForSearchArgs(dimensionManager.name, { - mode, - searchText, - values, - additionalFilter: otherDimsFilter, - }); - const optionsStore = $derived( - getQueryServiceMetricsViewAggregationQueryOptions( - client, - { - metricsView: mvName, - measures: [ - { - name: countMeasureName, - builtinMeasure: V1BuiltinMeasure.BUILTIN_MEASURE_COUNT_DISTINCT, - builtinMeasureArgs: [dimensionName], - }, - ], - timeRange: { start: timeStart, end: timeEnd, timeDimension }, - where, - }, - { - query: { - enabled, - select: (resp) => { - if (!resp.data?.length) return 0; - return resp.data[0][countMeasureName] as number; - }, - }, - }, - ), + 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] ?? {}, ); - return createQuery(optionsStore); } /** diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte index d73fbd66c99a..f56d8f463bbb 100644 --- a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte @@ -110,7 +110,7 @@ {dimensionColumnWidth} sortedAscending={$sortedAscending} sortType={$sortType} - filterExcludeMode={expressionFilterManager.dimensionFilterManagers.find( + filterExcludeMode={expressionFilterManager.filterManagers.dimensions.find( (dfm) => dfm.name === dimension.name, )?.exclude ?? false} {comparisonTimeRange} diff --git a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts index 98da5a5baf21..ca3f5d230487 100644 --- a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -9,7 +9,7 @@ export class YAMLConfigProvider { public requiredFilters = $state>({}); public specRequiredFilters = $state>({}); - public constructor(public readonly editable: boolean) {} + public constructor(public readonly editable: boolean = false) {} public syncWithCanvas(canvasSpec: V1CanvasSpec) { const pinnedFilters = canvasSpec.pinnedFilters 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 fb254ec20efa..8071a8310487 100644 --- a/web-common/src/features/dashboards/state-managers/actions/filters.ts +++ b/web-common/src/features/dashboards/state-managers/actions/filters.ts @@ -1,6 +1,10 @@ import type { DashboardMutables } from "@rilldata/web-common/features/dashboards/state-managers/actions/types"; -import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { + createAndExpression, + filterExpressions, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import type { V1Expression } from "@rilldata/web-common/runtime-client"; +import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils.ts"; export function clearAllFilters({ dashboard }: DashboardMutables) { const hasFilters = @@ -26,11 +30,15 @@ export function setTemporaryFilterName( export function setFilter( { dashboard }: DashboardMutables, - expr: V1Expression, + expr: V1Expression | undefined, inList: string[], ) { - dashboard.whereFilter = expr; - dashboard.dimensionThresholdFilters = inList; + const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter( + expr ? filterExpressions(expr, () => true) : undefined, + ); + dashboard.whereFilter = dimensionFilters; + dashboard.dimensionThresholdFilters = dimensionThresholdFilters; + dashboard.dimensionsWithInlistFilter = inList; } export const filterActions = { diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte index 676c72594dbc..1540a5192b15 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte @@ -86,7 +86,7 @@ ); const excludeMode = $derived( - expressionFilterManager.dimensionFilterManagers.find( + expressionFilterManager.filterManagers.dimensions.find( (dfm) => dfm.name === dimensionName, )?.exclude ?? false, ); diff --git a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte index 4edf67613f33..240312f4bb29 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte @@ -95,7 +95,7 @@ }); const formattedData = $derived(timeDimensionDataCopy); const excludeMode = $derived( - expressionFilterManager.dimensionFilterManagers.find( + expressionFilterManager.filterManagers.dimensions.find( (dfm) => dfm.name === dimensionName, )?.exclude ?? false, ); @@ -118,9 +118,9 @@ // Create a time formatter for the column headers const timeFormatter = $derived( - timeFormat( - timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", - ) as (d: Date) => string, + timeFormat(timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M") as ( + d: Date, + ) => string, ); function highlightCell(x: number | undefined, y: number | undefined) { diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 324782dcd285..1c5cd2bef888 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -149,7 +149,7 @@ return Interval.fromDateTimes(start, end) as Interval; })(); $: includedValuesForDimension = - expressionFilterManager.dimensionFilterManagers.find( + expressionFilterManager.filterManagers.dimensions.find( (dfm) => dfm.name === comparisonDimension, )?.selectedValues ?? []; $: chartDimensionValues = includedValuesForDimension.slice( diff --git a/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte b/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte index 23f5c3e57b98..bdd22d7d7e92 100644 --- a/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte +++ b/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte @@ -235,7 +235,7 @@ ], ); updatedAggregationRequest.where = sanitiseExpression( - filters.expr, + filters.exprByMetricsView[metricsViewName], undefined, ); diff --git a/web-common/src/lib/svelte-query/reactive-queries.svelte.ts b/web-common/src/lib/svelte-query/reactive-queries.svelte.ts new file mode 100644 index 000000000000..b1c01e3b0246 --- /dev/null +++ b/web-common/src/lib/svelte-query/reactive-queries.svelte.ts @@ -0,0 +1,63 @@ +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; +import type { ConnectError } from "@connectrpc/connect"; +import { + createQueries, + type CreateQueryOptions, + type QueryObserverResult, +} from "@tanstack/svelte-query"; +import { fromStore, writable } from "svelte/store"; + +export type ReactiveQueriesResult = { + data: TData; + error: ConnectError | null; + isFetching: boolean; + isLoading: boolean; +}; + +/** + * Runs one query per entry in `getQueryOptions` and combines their results into a single reactive + * result. + * + * The option list is rebuilt whenever the state it reads changes, so queries are added, removed and + * refetched without recreating the observer. `getData` receives the data of each query in the order + * of the option list. + * + * Must be called during component init, since it owns an effect. + */ +export function createReactiveQueries( + getQueryOptions: () => CreateQueryOptions< + TQueryFnData, + ConnectError, + TQueryData + >[], + getData: (data: (TQueryData | undefined)[]) => TData, +): { readonly current: ReactiveQueriesResult } { + // svelte-query takes the options as a store rather than as a rune. Pushing into the store lets a + // single observer follow the option list, instead of recreating the queries on every change. + const queriesStore = writable(getQueryOptions()); + $effect(() => { + queriesStore.set(getQueryOptions()); + }); + + return fromStore( + createQueries( + { + queries: queriesStore, + combine: (anyRes: any) => { + // TODO: fix types to not need this + const results = anyRes as QueryObserverResult< + TQueryData, + ConnectError + >[]; + return { + data: getData(results.map((result) => result.data)), + error: results.find((result) => !!result.error)?.error ?? null, + isFetching: results.some((result) => result.isFetching), + isLoading: results.some((result) => result.isLoading), + }; + }, + }, + queryClient, + ), + ); +} From 8b4057e87278906911c2ffde7bfba941cfd5c829 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Fri, 31 Jul 2026 17:58:14 +0530 Subject: [PATCH 08/21] Migrate canvas pass 1 --- .../bookmarks/BookmarksFormDialog.svelte | 155 +++--- .../src/features/alerts/create-alert-utils.ts | 4 +- .../AlertDialogCriteriaTab.svelte | 2 +- .../alerts/criteria-tab/AlertPreview.svelte | 2 +- .../alerts/data-tab/AlertDialogDataTab.svelte | 2 +- .../alerts/data-tab/DataPreview.svelte | 2 +- web-common/src/features/alerts/form-utils.ts | 2 - .../canvas/filters/CanvasFilters.svelte | 188 ++----- .../features/canvas/stores/canvas-entity.ts | 44 +- .../dashboards/aggregation-request-utils.ts | 19 - .../ExpressionFilterManager.svelte.ts | 14 +- .../{manager => }/ExpressionFilters.svelte | 10 +- .../dashboards/filters/Filters.svelte | 2 +- .../filters/ReadonlyExpressionFilters.svelte | 86 +++ .../DimensionFilter.svelte | 41 +- .../DimensionFilterManager.svelte.ts | 0 .../ReadonlyDimensionFilter.svelte | 120 ++++ .../queries.svelte.ts | 2 +- .../dashboards/filters/manager/types.ts | 6 - .../MeasureFilter.svelte | 20 +- .../MeasureFilterManager.svelte.ts | 0 .../ReadonlyMeasureFilter.svelte | 47 ++ .../providers/YAMLConfigProvider.svelte.ts | 66 ++- .../loaders/DashboardStateSync.ts | 2 +- .../state-managers/state-managers.ts | 19 +- .../src/features/dashboards/stores/Filters.ts | 520 ------------------ .../providers/MetricsViewsProvider.svelte.ts | 11 +- .../BaseScheduledReportForm.svelte | 2 +- .../scheduled-reports/FiltersForm.svelte | 4 +- .../src/features/scheduled-reports/utils.ts | 2 +- 30 files changed, 516 insertions(+), 878 deletions(-) rename web-common/src/features/dashboards/filters/{manager => }/ExpressionFilterManager.svelte.ts (95%) rename web-common/src/features/dashboards/filters/{manager => }/ExpressionFilters.svelte (90%) create mode 100644 web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte rename web-common/src/features/dashboards/filters/{manager => dimension-filters-v2}/DimensionFilter.svelte (91%) rename web-common/src/features/dashboards/filters/{manager => dimension-filters-v2}/DimensionFilterManager.svelte.ts (100%) create mode 100644 web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte rename web-common/src/features/dashboards/filters/{manager => dimension-filters-v2}/queries.svelte.ts (98%) delete mode 100644 web-common/src/features/dashboards/filters/manager/types.ts rename web-common/src/features/dashboards/filters/{manager => measure-filters-v2}/MeasureFilter.svelte (81%) rename web-common/src/features/dashboards/filters/{manager => measure-filters-v2}/MeasureFilterManager.svelte.ts (100%) create mode 100644 web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte delete mode 100644 web-common/src/features/dashboards/stores/Filters.ts diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index 69a689de7a5c..d55e2b325309 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -1,5 +1,5 @@ @@ -286,24 +285,12 @@ {m.bookmark_filters_inherited()}
- {#if filterState && "uiFilters" in filterState} - - {:else if filterState} - {/if}
@@ -317,7 +304,7 @@ { value: "true", label: m.bookmark_managed_bookmarks() }, ]} slot="manage-project" - tooltip={CategoryTooltip} + tooltip={m.bookmark_category_tooltip()} /> {#if showFiltersOnly} @@ -366,9 +353,9 @@
- {#if filterState} + {#if timeFilterState}
- {filterState.selectedTimeRange} + {timeFilterState.selectedTimeRange}
{/if} diff --git a/web-common/src/features/alerts/create-alert-utils.ts b/web-common/src/features/alerts/create-alert-utils.ts index 4e89f9a2734c..ed62ce6d0092 100644 --- a/web-common/src/features/alerts/create-alert-utils.ts +++ b/web-common/src/features/alerts/create-alert-utils.ts @@ -3,14 +3,12 @@ import { getSnoozeOptions } from "@rilldata/web-common/features/alerts/delivery- import type { AlertFormValues } from "@rilldata/web-common/features/alerts/form-utils.ts"; import { getEmptyMeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts"; import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state.ts"; -import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; -import { Filters } from "@rilldata/web-common/features/dashboards/stores/Filters.ts"; import { ExploreMetricsViewMetadata } from "@rilldata/web-common/features/dashboards/stores/ExploreMetricsViewMetadata.ts"; import { TimeControls } from "@rilldata/web-common/features/dashboards/stores/TimeControls.ts"; import { getInitialScheduleFormValues } from "@rilldata/web-common/features/scheduled-reports/time-utils.ts"; import { V1Operation } from "@rilldata/web-common/runtime-client"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; -import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils.ts"; import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; diff --git a/web-common/src/features/alerts/criteria-tab/AlertDialogCriteriaTab.svelte b/web-common/src/features/alerts/criteria-tab/AlertDialogCriteriaTab.svelte index 7bd98a7ac609..d0ed016b781d 100644 --- a/web-common/src/features/alerts/criteria-tab/AlertDialogCriteriaTab.svelte +++ b/web-common/src/features/alerts/criteria-tab/AlertDialogCriteriaTab.svelte @@ -6,7 +6,7 @@ import type { AlertFormValues } from "@rilldata/web-common/features/alerts/form-utils"; import type { TimeControls } from "@rilldata/web-common/features/dashboards/stores/TimeControls.ts"; import type { SuperForm } from "sveltekit-superforms/client"; - import type { ExpressionFilterManager } from "../../dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; + import type { ExpressionFilterManager } from "../../dashboards/filters/ExpressionFilterManager.svelte.ts"; export let superFormInstance: SuperForm; export let filters: ExpressionFilterManager; diff --git a/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte b/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte index 0d8bc49e956b..911d23f0d691 100644 --- a/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte +++ b/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte @@ -11,7 +11,7 @@ import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import type { DimensionTableRow } from "../../dashboards/dimension-table/dimension-table-types"; - import type { ExpressionFilterManager } from "../../dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; + import type { ExpressionFilterManager } from "../../dashboards/filters/ExpressionFilterManager.svelte.ts"; let { formValues, diff --git a/web-common/src/features/alerts/data-tab/AlertDialogDataTab.svelte b/web-common/src/features/alerts/data-tab/AlertDialogDataTab.svelte index 31984898ecf4..fb2d9a1ed573 100644 --- a/web-common/src/features/alerts/data-tab/AlertDialogDataTab.svelte +++ b/web-common/src/features/alerts/data-tab/AlertDialogDataTab.svelte @@ -10,7 +10,7 @@ import Select from "../../../components/forms/Select.svelte"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { useMetricsViewValidSpec } from "../../dashboards/selectors"; - import type { ExpressionFilterManager } from "../../dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; + import type { ExpressionFilterManager } from "../../dashboards/filters/ExpressionFilterManager.svelte.ts"; export let superFormInstance: SuperForm; export let filters: ExpressionFilterManager; diff --git a/web-common/src/features/alerts/data-tab/DataPreview.svelte b/web-common/src/features/alerts/data-tab/DataPreview.svelte index 369f4144777a..dd6f60bb45e9 100644 --- a/web-common/src/features/alerts/data-tab/DataPreview.svelte +++ b/web-common/src/features/alerts/data-tab/DataPreview.svelte @@ -10,7 +10,7 @@ import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import PreviewEmpty from "../PreviewEmpty.svelte"; import type { DimensionTableRow } from "../../dashboards/dimension-table/dimension-table-types"; - import type { ExpressionFilterManager } from "../../dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; + import type { ExpressionFilterManager } from "../../dashboards/filters/ExpressionFilterManager.svelte.ts"; let { formValues, diff --git a/web-common/src/features/alerts/form-utils.ts b/web-common/src/features/alerts/form-utils.ts index 1e32bd12f50c..50794f0b9bd8 100644 --- a/web-common/src/features/alerts/form-utils.ts +++ b/web-common/src/features/alerts/form-utils.ts @@ -6,13 +6,11 @@ import { type MeasureFilterEntry, } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; import { MeasureFilterType } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options"; -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 { mapSelectedComparisonTimeRangeToV1TimeRange, mapSelectedTimeRangeToV1TimeRange, } from "@rilldata/web-common/features/dashboards/time-controls/time-range-mappers.ts"; -import type { FiltersState } from "@rilldata/web-common/features/dashboards/stores/Filters.ts"; import type { TimeControlState } from "@rilldata/web-common/features/dashboards/stores/TimeControls.ts"; import { getInitialScheduleFormValues } from "@rilldata/web-common/features/scheduled-reports/time-utils.ts"; import type { diff --git a/web-common/src/features/canvas/filters/CanvasFilters.svelte b/web-common/src/features/canvas/filters/CanvasFilters.svelte index 761a6a466698..cf9c1939b9f9 100644 --- a/web-common/src/features/canvas/filters/CanvasFilters.svelte +++ b/web-common/src/features/canvas/filters/CanvasFilters.svelte @@ -1,56 +1,31 @@ + +
+ {#if displayTimeRange} + + {/if} + + {#each expressionFilterManager.filterManagers.dimensions as dimensionManager (dimensionManager.name)} +
+ +
+ {/each} + + {#each expressionFilterManager.filterManagers.measures as measureManager (measureManager.name)} +
+ +
+ {/each} +
+ + diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte similarity index 91% rename from web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte rename to web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte index 5284ec040b20..9d8cf70cc064 100644 --- a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte @@ -1,44 +1,43 @@ + + + + diff --git a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters-v2/queries.svelte.ts similarity index 98% rename from web-common/src/features/dashboards/filters/manager/queries.svelte.ts rename to web-common/src/features/dashboards/filters/dimension-filters-v2/queries.svelte.ts index 0d3c22254b46..11184b0611e1 100644 --- a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/queries.svelte.ts @@ -1,5 +1,5 @@ import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; -import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; import { getQueryServiceMetricsViewAggregationQueryOptions, V1BuiltinMeasure, diff --git a/web-common/src/features/dashboards/filters/manager/types.ts b/web-common/src/features/dashboards/filters/manager/types.ts deleted file mode 100644 index 44398b69b135..000000000000 --- a/web-common/src/features/dashboards/filters/manager/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { V1Expression } from "@rilldata/web-common/runtime-client"; - -export type RawParsedFilter = { - expr: V1Expression | undefined; - dimensionsWithInlistFilter: string[]; -}; diff --git a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte similarity index 81% rename from web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte rename to web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte index ab5c5631b3e2..a309c21349ad 100644 --- a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte @@ -1,17 +1,17 @@ + + + + diff --git a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts index ca3f5d230487..6481933dc22e 100644 --- a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -1,33 +1,22 @@ -import type { V1CanvasSpec } from "@rilldata/web-common/runtime-client"; +import { + createQueryServiceResolveCanvas, + type V1Expression, +} from "@rilldata/web-common/runtime-client"; +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; /** * 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 constructor(public readonly editable: boolean = false) {} + public cleanup: (() => void) | undefined = undefined; - public syncWithCanvas(canvasSpec: V1CanvasSpec) { - const pinnedFilters = canvasSpec.pinnedFilters - ? Object.fromEntries( - canvasSpec.pinnedFilters.map((filter) => [filter, true]), - ) - : {}; - this.pinnedFilters = { ...pinnedFilters }; - this.specPinnedFilters = { ...pinnedFilters }; - - const requiredFilters = canvasSpec.requiredFilters - ? Object.fromEntries( - canvasSpec.requiredFilters.map((filter) => [filter, true]), - ) - : {}; - this.requiredFilters = { ...requiredFilters }; - this.specRequiredFilters = { ...requiredFilters }; - } + public constructor(public readonly editable: boolean = false) {} public togglePinnedFilter(filter: string) { this.pinnedFilters[filter] = !this.pinnedFilters[filter]; @@ -37,3 +26,42 @@ export class YAMLConfigProvider { this.requiredFilters[filter] = !this.requiredFilters[filter]; } } + +export class CanvasConfigProvider extends YAMLConfigProvider { + public constructor( + runtimeClient: RuntimeClient, + canvasName: string, + editable: boolean = false, + ) { + super(editable); + + const resolveCanvasQuery = createQueryServiceResolveCanvas(runtimeClient, { + canvas: canvasName, + }); + this.cleanup = resolveCanvasQuery.subscribe((resolveCanvasResp) => { + const canvasSpec = resolveCanvasResp.data?.canvas ?? {}; + + this.defaultFilters = Object.fromEntries( + Object.entries(canvasSpec.defaultPreset?.filterExpr ?? {}).map( + ([mv, sqlFilter]) => [mv, sqlFilter.expression], + ), + ); + + const pinnedFilters = canvasSpec.pinnedFilters + ? Object.fromEntries( + canvasSpec.pinnedFilters.map((filter) => [filter, true]), + ) + : {}; + this.pinnedFilters = { ...pinnedFilters }; + this.specPinnedFilters = { ...pinnedFilters }; + + const requiredFilters = canvasSpec.requiredFilters + ? Object.fromEntries( + canvasSpec.requiredFilters.map((filter) => [filter, true]), + ) + : {}; + this.requiredFilters = { ...requiredFilters }; + this.specRequiredFilters = { ...requiredFilters }; + }); + } +} 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 1882c990a831..a25751ef1436 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -21,7 +21,7 @@ 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; -import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); 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 5d0fbe946c9f..a80899994f1f 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -33,7 +33,7 @@ import { contextColWidthDefaults, type ContextColWidths, } from "../leaderboard-context-column"; -import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; 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"; @@ -69,6 +69,7 @@ export type StateManagers = { contextColumnWidths: Writable; defaultExploreState: Readable; expressionFilterManager: ExpressionFilterManager; + cleanup: () => void; }; export const DEFAULT_STORE_KEY = Symbol("state-managers"); @@ -167,6 +168,14 @@ export function createStateManagers({ }, ); + const metricsViewProvider = new MetricsViewsProvider(runtimeClient, [ + metricsViewName, + ]); + const expressionFilterManager = new ExpressionFilterManager( + metricsViewProvider, + new YAMLConfigProvider(), + ); + return { runtimeClient, metricsViewName: metricsViewNameStore, @@ -195,9 +204,9 @@ export function createStateManagers({ }), contextColumnWidths, defaultExploreState, - expressionFilterManager: new ExpressionFilterManager( - new MetricsViewsProvider(runtimeClient, [metricsViewName]), - new YAMLConfigProvider(), - ), + expressionFilterManager, + cleanup: () => { + metricsViewProvider.cleanup(); + }, }; } 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>; - public readonly temporaryFilterName: Writable; - - // ------------------- - // "SELECTORS" (readable/derived) - // ------------------- - public readonly measureFilterItems: Readable; - public readonly allMeasureFilterItems: Readable; - public readonly measureHasFilter: Readable<(measureName: string) => boolean>; - - public readonly dimensionFilterItems: Readable; - public readonly allDimensionFilterItems: Readable; - public readonly isFilterExcludeMode: Readable<(dimName: string) => boolean>; - public readonly dimensionHasFilter: Readable<(dimName: string) => boolean>; - - public readonly hasFilters: Readable; - - constructor( - public readonly metricsViewMetadata: ExploreMetricsViewMetadata, - { - whereFilter, - dimensionsWithInlistFilter, - dimensionThresholdFilters, - dimensionFilterExcludeMode, - }: FiltersState, - ) { - // ----------------------------- - // Initialize writable stores - // Lot of these are edited in place. So create a copy to avoid updating the original. - // ----------------------------- - this.whereFilter = writable(copyFilterExpression(whereFilter)); - this.dimensionsWithInlistFilter = writable([...dimensionsWithInlistFilter]); - this.dimensionThresholdFilters = writable( - structuredClone(dimensionThresholdFilters), - ); - this.dimensionFilterExcludeMode = writable( - new Map(dimensionFilterExcludeMode), - ); - this.temporaryFilterName = writable(null); - - // ------------------------------- - // MEASURE SELECTORS - // ------------------------------- - this.measureFilterItems = derived( - [this.metricsViewMetadata.measureNameMap, this.dimensionThresholdFilters], - ([$measureNameMap, $dimensionThresholdFilters]) => { - return this.getMeasureFilters( - $measureNameMap, - $dimensionThresholdFilters, - ); - }, - ); - - this.allMeasureFilterItems = derived( - [ - this.metricsViewMetadata.measureNameMap, - this.measureFilterItems, - this.temporaryFilterName, - ], - ([$measureNameMap, $measureFilterItems, tempFilter]) => { - const itemsCopy = [...$measureFilterItems]; - if (tempFilter && $measureNameMap.has(tempFilter)) { - itemsCopy.push({ - dimensionName: "", - name: tempFilter, - label: getMeasureDisplayName($measureNameMap.get(tempFilter)), - // dimensions, // TODO: for canvas - }); - } - return itemsCopy; - }, - ); - - this.measureHasFilter = derived( - this.dimensionThresholdFilters, - ($dimensionThresholdFilters) => { - return (measureName: string) => { - return $dimensionThresholdFilters.some((dtf) => - dtf.filters.some((f) => f.measure === measureName), - ); - }; - }, - ); - - // ------------------------------- - // DIMENSION SELECTORS - // ------------------------------- - this.dimensionFilterItems = derived( - [ - this.metricsViewMetadata.dimensionNameMap, - this.whereFilter, - this.dimensionsWithInlistFilter, - ], - ([$dimensionNameMap, $whereFilter, $dimensionsWithInlistFilter]) => { - // TODO: fill in metricsViewNames for each dimension filter item when integrated into canvas - return getDimensionFilters( - $dimensionNameMap, - $whereFilter, - $dimensionsWithInlistFilter, - this.metricsViewMetadata.metricsViewName, - ); - }, - ); - - this.allDimensionFilterItems = derived( - [ - this.metricsViewMetadata.dimensionNameMap, - this.dimensionFilterItems, - this.temporaryFilterName, - ], - ([$dimensionNameMap, $dimensionFilterItems, tempFilter]) => { - const merged = $dimensionFilterItems.map((dfi) => ({ - ...dfi, - metricsViewNames: [this.metricsViewMetadata.metricsViewName], - })); - if (tempFilter) { - const dimension = $dimensionNameMap.get(tempFilter); - if (dimension) { - merged.push({ - mode: DimensionFilterMode.Select, - name: tempFilter, - label: getDimensionDisplayName(dimension), - selectedValues: [], - isInclude: true, - metricsViewNames: [this.metricsViewMetadata.metricsViewName], - dimensions: new Map([ - [this.metricsViewMetadata.metricsViewName, dimension], - ]), - }); - } - } - - return merged.sort(filterItemsSortFunction); - }, - ); - - this.isFilterExcludeMode = derived( - this.dimensionFilterExcludeMode, - ($excludeMode) => { - return (dimName: string) => { - return $excludeMode.get(dimName) ?? false; - }; - }, - ); - - this.dimensionHasFilter = derived(this.whereFilter, ($whereFilter) => { - return (dimName: string) => { - return ( - $whereFilter.cond?.exprs?.find((e) => - matchExpressionByName(e, dimName), - ) !== undefined - ); - }; - }); - - this.hasFilters = derived( - [this.dimensionFilterItems, this.measureFilterItems], - ([$dimensionFilterItems, $measureFilterItems]) => - $dimensionFilterItems.length > 0 || $measureFilterItems.length > 0, - ); - } - - // -------------------- - // ACTIONS / MUTATORS - // -------------------- - - public setMeasureFilter = ( - dimensionName: string, - filter: MeasureFilterEntry, - ) => { - const tempFilter = get(this.temporaryFilterName); - if (tempFilter !== null) { - this.temporaryFilterName.set(null); - } - - const dtfs = get(this.dimensionThresholdFilters); - let dimThresholdFilter = dtfs.find((dtf) => dtf.name === dimensionName); - if (!dimThresholdFilter) { - dimThresholdFilter = { name: dimensionName, filters: [] }; - dtfs.push(dimThresholdFilter); - } - const exprIdx = dimThresholdFilter.filters.findIndex( - (f) => f.measure === filter.measure, - ); - if (exprIdx === -1) { - dimThresholdFilter.filters.push(filter); - } else { - dimThresholdFilter.filters.splice(exprIdx, 1, filter); - } - this.dimensionThresholdFilters.set(dtfs); - }; - - public removeMeasureFilter = (dimensionName: string, measureName: string) => { - const tempFilter = get(this.temporaryFilterName); - if (tempFilter === measureName) { - this.temporaryFilterName.set(null); - return; - } - const dtfs = get(this.dimensionThresholdFilters); - const dimIdx = dtfs.findIndex((dtf) => dtf.name === dimensionName); - if (dimIdx === -1) return; - const filters = dtfs[dimIdx].filters; - const exprIdx = filters.findIndex((f) => f.measure === measureName); - if (exprIdx === -1) return; - filters.splice(exprIdx, 1); - if (!filters.length) { - dtfs.splice(dimIdx, 1); - } - this.dimensionThresholdFilters.set(dtfs); - }; - - toggleDimensionValueSelection = ( - dimensionName: string, - dimensionValue: string, - keepPillVisible?: boolean, - isExclusiveFilter?: boolean, - ) => { - this.toggleMultipleDimensionValueSelections( - dimensionName, - [dimensionValue], - keepPillVisible, - isExclusiveFilter, - ); - }; - - toggleMultipleDimensionValueSelections = ( - dimensionName: string, - dimensionValues: string[], - keepPillVisible?: boolean, - isExclusiveFilter?: boolean, - exclude?: boolean, - ) => { - const tempFilter = get(this.temporaryFilterName); - if (tempFilter !== null) { - this.temporaryFilterName.set(null); - } - - const excludeMode = get(this.dimensionFilterExcludeMode); - if (exclude !== undefined) { - excludeMode.set(dimensionName, exclude); - this.dimensionFilterExcludeMode.set(excludeMode); - } - const isExclude = exclude ?? !!excludeMode.get(dimensionName); - const wf = get(this.whereFilter); - - // Use the derived selector: - let exprIndex = this.getWhereFilterExpressionIndex(dimensionName) ?? -1; - let expr = wf.cond?.exprs?.[exprIndex]; - - const wasLikeFilter = - expr?.cond?.op === V1Operation.OPERATION_LIKE || - expr?.cond?.op === V1Operation.OPERATION_NLIKE; - if (!expr?.cond?.exprs || wasLikeFilter) { - expr = createInExpression(dimensionName, [], isExclude); - wf.cond?.exprs?.push(expr); - exprIndex = wf.cond!.exprs!.length - 1; - } - - const wasInListFilter = get(this.dimensionsWithInlistFilter).includes( - dimensionName, - ); - if (wasInListFilter) { - this.dimensionsWithInlistFilter.update((dimensionsWithInlistFilter) => - dimensionsWithInlistFilter.filter((d) => d !== dimensionName), - ); - } - - dimensionValues.forEach((dimensionValue) => { - toggleDimensionFilterValue(expr, dimensionValue, !!isExclusiveFilter); - }); - - if (expr?.cond?.exprs?.length === 1) { - wf.cond?.exprs?.splice(exprIndex, 1); - - if (keepPillVisible) { - this.setTemporaryFilterName(dimensionName); - } - } - - this.whereFilter.set(wf); - }; - - public applyDimensionInListMode = ( - dimensionName: string, - values: string[], - ) => { - const tempFilter = get(this.temporaryFilterName); - if (tempFilter !== null) { - this.temporaryFilterName.set(null); - } - const excludeMode = get(this.dimensionFilterExcludeMode); - const isExclude = !!excludeMode.get(dimensionName); - const wf = get(this.whereFilter); - - const expr = createInExpression(dimensionName, values, isExclude); - this.dimensionsWithInlistFilter.update((dimensionsWithInlistFilter) => { - return [...dimensionsWithInlistFilter, dimensionName]; - }); - - const exprIndex = this.getWhereFilterExpressionIndex(dimensionName); - if (exprIndex === undefined || exprIndex === -1) { - wf.cond!.exprs!.push(expr); - } else { - wf.cond!.exprs![exprIndex] = expr; - } - this.whereFilter.set(wf); - }; - - public applyDimensionContainsMode = ( - dimensionName: string, - searchText: string, - ) => { - const tempFilter = get(this.temporaryFilterName); - if (tempFilter !== null) { - this.temporaryFilterName.set(null); - } - const excludeMode = get(this.dimensionFilterExcludeMode); - const isExclude = !!excludeMode.get(dimensionName); - const wf = get(this.whereFilter); - - const expr = createLikeExpression( - dimensionName, - `%${searchText}%`, - isExclude, - ); - const exprIndex = this.getWhereFilterExpressionIndex(dimensionName); - if (exprIndex === undefined || exprIndex === -1) { - wf.cond!.exprs!.push(expr); - } else { - wf.cond!.exprs![exprIndex] = expr; - } - this.whereFilter.set(wf); - }; - - public toggleDimensionFilterMode = (dimensionName: string) => { - const excludeMode = get(this.dimensionFilterExcludeMode); - const newExclude = !excludeMode.get(dimensionName); - excludeMode.set(dimensionName, newExclude); - this.dimensionFilterExcludeMode.set(excludeMode); - - const wf = get(this.whereFilter); - if (!wf.cond?.exprs) return; - const exprIdx = wf.cond.exprs.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ); - if (exprIdx === -1) return; - wf.cond.exprs[exprIdx] = negateExpression(wf.cond.exprs[exprIdx]); - this.whereFilter.set(wf); - }; - - public removeDimensionFilter = (dimensionName: string) => { - const tempFilter = get(this.temporaryFilterName); - if (tempFilter === dimensionName) { - this.temporaryFilterName.set(null); - return; - } - const wf = get(this.whereFilter); - const exprIdx = this.getWhereFilterExpressionIndex(dimensionName); - if (exprIdx === undefined || exprIdx === -1) return; - wf.cond?.exprs?.splice(exprIdx, 1); - this.whereFilter.set(wf); - }; - - public setTemporaryFilterName = (name: string) => { - this.temporaryFilterName.set(name); - }; - - public toState(): FiltersState { - return { - whereFilter: get(this.whereFilter), - dimensionThresholdFilters: get(this.dimensionThresholdFilters), - dimensionsWithInlistFilter: get(this.dimensionsWithInlistFilter), - dimensionFilterExcludeMode: get(this.dimensionFilterExcludeMode), - }; - } - - public getStore(): Readable { - return derived( - [ - this.whereFilter, - this.dimensionThresholdFilters, - this.dimensionsWithInlistFilter, - this.dimensionFilterExcludeMode, - ], - ([ - whereFilter, - dimensionThresholdFilters, - dimensionsWithInlistFilter, - dimensionFilterExcludeMode, - ]) => ({ - whereFilter, - dimensionThresholdFilters, - dimensionsWithInlistFilter, - dimensionFilterExcludeMode, - }), - ); - } - - public clearAllFilters = () => { - const wf = get(this.whereFilter); - const dtfs = get(this.dimensionThresholdFilters); - const hasFilters = wf.cond?.exprs?.length || dtfs.length; - if (!hasFilters) return; - this.whereFilter.set(createAndExpression([])); - this.dimensionThresholdFilters.set([]); - this.temporaryFilterName.set(null); - const excludeMode = get(this.dimensionFilterExcludeMode); - excludeMode.clear(); - this.dimensionFilterExcludeMode.set(excludeMode); - }; - - private getMeasureFilters( - measureNameMap: Map, - dimensionThresholdFilters: DimensionThresholdFilter[], - ): MeasureFilterItem[] { - return dedupe( - dimensionThresholdFilters - .map((dtf) => - this.getMeasureFilterForDimension( - measureNameMap, - dtf.filters, - dtf.name, - ), - ) - .flat(), - (i) => i.name, - ); - } - - private getMeasureFilterForDimension( - measureNameMap: Map, - filters: MeasureFilterEntry[], - name: string, - ): MeasureFilterItem[] { - return filters - .map((filter) => { - const measure = measureNameMap.get(filter.measure); - if (!measure) return undefined; - return { - dimensionName: name, - name: filter.measure, - label: measure.displayName || measure.expression || filter.measure, - filter, - // dimensions, // TODO: for canvas - }; - }) - .filter(Boolean) as MeasureFilterItem[]; - } - - private getWhereFilterExpressionIndex(name: string) { - const $whereFilter = get(this.whereFilter); - return $whereFilter.cond?.exprs?.findIndex((e) => - matchExpressionByName(e, name), - ); - } -} diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts index 6eade417e21c..afa459e0fc5d 100644 --- a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -53,15 +53,18 @@ export class MetricsViewsProvider { 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; + public cleanup: () => void; // TODO: ensure this is called by creators private readonly timeRangeUnsubs = new Map void>(); public constructor( public readonly runtimeClient: RuntimeClient, - public readonly metricsViewNames: string[], + initMetricsViewNames: string[], ) { + this.metricsViewNames = initMetricsViewNames; + const allResourcesQuery = createRuntimeServiceListResources( runtimeClient, {}, @@ -135,6 +138,10 @@ export class MetricsViewsProvider { }; } + public setMetricsViewNames(metricsViewNames: string[]) { + this.metricsViewNames = metricsViewNames; + } + private processResources(resources: V1Resource[]) { const specs: Record = {}; diff --git a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte index e8c8bf6f87b2..4691fd8db815 100644 --- a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte +++ b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte @@ -23,7 +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/manager/ExpressionFilterManager.svelte.ts"; + import type { ExpressionFilterManager } from "../dashboards/filters/ExpressionFilterManager.svelte.ts"; export let formId: string; export let data: Readable; diff --git a/web-common/src/features/scheduled-reports/FiltersForm.svelte b/web-common/src/features/scheduled-reports/FiltersForm.svelte index c8df003d48f6..22c340e24b4e 100644 --- a/web-common/src/features/scheduled-reports/FiltersForm.svelte +++ b/web-common/src/features/scheduled-reports/FiltersForm.svelte @@ -19,9 +19,9 @@ import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { DateTime, Interval } from "luxon"; import { onMount } from "svelte"; - import type { ExpressionFilterManager } from "../dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; + import type { ExpressionFilterManager } from "../dashboards/filters/ExpressionFilterManager.svelte.ts"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts"; - import ExpressionFilters from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilters.svelte"; + import ExpressionFilters from "../dashboards/filters/ExpressionFilters.svelte"; const runtimeClient = useRuntimeClient(); diff --git a/web-common/src/features/scheduled-reports/utils.ts b/web-common/src/features/scheduled-reports/utils.ts index e83d8d2909f7..7bdada8647d5 100644 --- a/web-common/src/features/scheduled-reports/utils.ts +++ b/web-common/src/features/scheduled-reports/utils.ts @@ -25,7 +25,7 @@ import { type V1TimeRangeSummary, } from "@rilldata/web-common/runtime-client"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; -import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/ExpressionFilterManager.svelte.ts"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; 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"; From c4ef0159273f236b207e31cd50ab613a7f474b7c Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Mon, 3 Aug 2026 11:23:44 +0530 Subject: [PATCH 09/21] Migrate bookmarks and public url form --- .../bookmarks/BookmarksFormDialog.svelte | 7 +- .../public-urls/CanvasFiltersSection.svelte | 99 ------------ .../public-urls/CreatePublicURLForm.svelte | 153 +++++++++++------- .../public-urls/ExploreFiltersSection.svelte | 93 ----------- .../features/public-urls/canvas-form-utils.ts | 66 -------- .../src/features/public-urls/form-utils.ts | 114 +++++++++++-- .../src/features/alerts/create-alert-utils.ts | 4 +- .../filters/ExpressionFilterManager.svelte.ts | 145 +++++++++-------- .../loaders/DashboardStateSync.ts | 12 +- .../dashboards/stores/filter-utils.ts | 8 + .../src/features/scheduled-reports/utils.ts | 7 +- 11 files changed, 293 insertions(+), 415 deletions(-) delete mode 100644 web-admin/src/features/public-urls/CanvasFiltersSection.svelte delete mode 100644 web-admin/src/features/public-urls/ExploreFiltersSection.svelte delete mode 100644 web-admin/src/features/public-urls/canvas-form-utils.ts diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index d55e2b325309..6e07015827d5 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -69,17 +69,20 @@ const runtimeClient = useRuntimeClient(); let { name: resourceName, kind: resourceKind } = $derived(resource); + const metricsViewsProvider = new MetricsViewsProvider(runtimeClient, []); + $effect(() => metricsViewsProvider.setMetricsViewNames(metricsViewNames)); let expressionFilterManager = $derived( new ExpressionFilterManager( - new MetricsViewsProvider(runtimeClient, []), + metricsViewsProvider, resourceKind === ResourceKind.Canvas ? new CanvasConfigProvider(runtimeClient, resourceName) : new YAMLConfigProvider(), ), ); - // TODO: make ExpressionFilterManager reactive + let { setUrlParams } = $derived(expressionFilterManager); let curUrlParams = $derived(page.url.searchParams); + $effect(() => setUrlParams(curUrlParams)); let timeFilterState = $state< | { diff --git a/web-admin/src/features/public-urls/CanvasFiltersSection.svelte b/web-admin/src/features/public-urls/CanvasFiltersSection.svelte deleted file mode 100644 index 47c43af1dd50..000000000000 --- a/web-admin/src/features/public-urls/CanvasFiltersSection.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - -{#if hasSomeFilter} -
- -
-

- {m.public_url_filters_locked_hidden()} -

- {#if canvasAppliedFiltersStore} -
- -
- {/if} -
-{/if} diff --git a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte index 613fb08b1f8b..13a48b3f8220 100644 --- a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte +++ b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte @@ -1,11 +1,9 @@ {#if !url} @@ -228,17 +247,27 @@ {/if}
--> - {#if isCanvas} - - {:else} - + {#if hasSomeFilter} +
+ +
+

+ {m.public_url_filters_locked_hidden()} +

+
+ +
+
+ + {#if isExplore} +

+ {m.public_url_measures_dimensions_limited()} +

+ {/if} {/if} - {/if} - - + {/if} diff --git a/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte b/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte index 8bdf4d327da4..1a398e0501a4 100644 --- a/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte +++ b/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte @@ -11,7 +11,7 @@ $: ({ specStore, type, - localFilters, + localExpressionFilters, localTimeControls, parent: { name: canvasName }, timeAndFilterStore, @@ -28,8 +28,13 @@ $: excludedDimensions = type === "leaderboard" - ? new Set((localParamValues as LeaderboardSpec).dimensions) - : new Set(); + ? Object.fromEntries( + (localParamValues as LeaderboardSpec).dimensions.map((d) => [ + d, + true, + ]), + ) + : {}; $: entries = Object.entries(inputParams) as [ AllKeys, @@ -55,9 +60,7 @@ {/if} {:else if config.type == "dimension_filters" && metricsView} { component.updateProperty("dimension_filters", newString); }} diff --git a/web-common/src/features/dashboards/filters/AddExpressionFilterButton.svelte b/web-common/src/features/dashboards/filters/AddExpressionFilterButton.svelte new file mode 100644 index 000000000000..952384392498 --- /dev/null +++ b/web-common/src/features/dashboards/filters/AddExpressionFilterButton.svelte @@ -0,0 +1,122 @@ + + + + + {#snippet child({ props })} + + + {m.dashboard_add_filter()} + + {/snippet} + + + expressionFilterManager.addNewFilter(name)} + {selectableGroups} + selectedItems={[]} + {side} + /> + + + diff --git a/web-common/src/features/dashboards/filters/ExploreFilterChipsReadOnly.svelte b/web-common/src/features/dashboards/filters/ExploreFilterChipsReadOnly.svelte deleted file mode 100644 index 7f7772b4e5d9..000000000000 --- a/web-common/src/features/dashboards/filters/ExploreFilterChipsReadOnly.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - - diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 425814a4bb80..b2f34d8dd69c 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -15,7 +15,7 @@ import { getMeasureDisplayName, } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts"; -import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; @@ -42,15 +42,16 @@ export class ExpressionFilterManager { /** True when the param cannot be represented as chips. */ public isComplexFilter: boolean; public exprByMetricsView: Record; + public hasSomeFilter: boolean; public filterManagers: { measures: MeasureFilterManager[]; dimensions: DimensionFilterManager[]; }; + public readonly filterManagersMap: Record; private prevUrlParams = $state(""); private parsedUrlParams = $state({}); - private readonly filterManagersMap: Record; public constructor( public readonly metricsViewsProvider: MetricsViewsProvider, @@ -73,6 +74,9 @@ export class ExpressionFilterManager { this.exprByMetricsView = $derived.by(() => this.buildExpressionsByMetricsView(), ); + this.hasSomeFilter = $derived( + Object.keys(this.exprByMetricsView).length > 0, + ); } public setUrlParams = (searchParams: URLSearchParams) => { @@ -108,6 +112,11 @@ export class ExpressionFilterManager { this.setUrlParams(new URLSearchParams(`${paramKey}=${filterParam}`)); } + public setParamForMetricsView(mvName: string, param: string) { + const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; + this.setUrlParams(new URLSearchParams(`${paramKey}=${param}`)); + } + /** Adds a chip for a filter that has no value yet. The chip opens as soon as it is rendered. */ public addNewFilter(name: string) { if ( @@ -161,6 +170,15 @@ export class ExpressionFilterManager { return exprs.length === 0 ? undefined : createAndExpression(exprs); } + public createLocalFilterStore(metricsViewName: string) { + return new ExpressionFilterManager( + new MetricsViewsProvider(this.metricsViewsProvider.runtimeClient, [ + metricsViewName, + ]), + this.yamlConfigProvider, + ); + } + /** Managers for the filters in the param, keyed by name and in param order. */ private buildParamFilterManagers() { const measuresMap = new Map(); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/ExpressionFilters.svelte index f28b39690f3d..dcb198aec60a 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/ExpressionFilters.svelte @@ -1,15 +1,16 @@ + +
{#if !hasFilters} @@ -114,14 +124,12 @@ {/each} {/if} - - expressionFilterManager.addNewFilter(name)} + + {#if hasClearableFilters} diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index b9c967170884..2e2bb443c69f 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -475,6 +475,7 @@ !!dimensionManager.expr, + ), + ); + let nonEmptyMeasureManager = $derived( + expressionFilterManager.filterManagers.measures.filter( + (measureManager) => !!measureManager.expr, + ), + ); + function handleWheel(event: WheelEvent) { if (chipLayout === "scroll" && event.deltaY !== 0) { scrollContainer.scrollLeft += event.deltaY; @@ -39,7 +53,8 @@ class="relative flex flex-row items-center gap-x-2 gap-y-2 w-full max-w-full" class:scrollable-chips={chipLayout === "scroll"} class:flex-wrap={chipLayout === "wrap"} - aria-label={m.dashboard_readonly_filter_chips_aria()} + class:flex-col={chipLayout === "col"} + aria-label={ariaLabel} bind:this={scrollContainer} onwheel={handleWheel} > @@ -51,7 +66,7 @@ /> {/if} - {#each expressionFilterManager.filterManagers.dimensions as dimensionManager (dimensionManager.name)} + {#each nonEmptyDimensionManagers as dimensionManager (dimensionManager.name)}
{/each} - {#each expressionFilterManager.filterManagers.measures as measureManager (measureManager.name)} + {#each nonEmptyMeasureManager as measureManager (measureManager.name)}
+ import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; + import InputLabel from "@rilldata/web-common/components/forms/InputLabel.svelte"; + import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; + import AddExpressionFilterButton from "@rilldata/web-common/features/dashboards/filters/AddExpressionFilterButton.svelte"; + import DimensionFilter from "@rilldata/web-common/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte"; + import MeasureFilter from "@rilldata/web-common/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte"; + import Button from "../../../components/button/Button.svelte"; + + let { + id = "expression-filter", + expressionFilterManager, + excludedDimensions, + excludedMeasures, + }: { + id?: string; + expressionFilterManager: ExpressionFilterManager; + excludedDimensions?: Record; + excludedMeasures?: Record; + } = $props(); + + let metricsViewsProvider = $derived( + expressionFilterManager.metricsViewsProvider, + ); + let yamlConfigProvider = $derived(expressionFilterManager.yamlConfigProvider); + + let allDimensions = $derived(metricsViewsProvider.dimensions); + + let hasFilters = $derived( + expressionFilterManager.filterManagers.dimensions.length > 0 || + expressionFilterManager.filterManagers.measures.length > 0, + ); + + +
+ + + +
+ + + +
+
+ {#each expressionFilterManager.filterManagers.dimensions as dimensionManager (dimensionManager.name)} + + {/each} + + {#each expressionFilterManager.filterManagers.measures as measureManager (measureManager.name)} + + {/each} +
+ +
+ {#if hasFilters} + + {/if} +
+
diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte index 9d8cf70cc064..8c00c85f47a7 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte @@ -52,7 +52,7 @@ openOnMount?: boolean; timeStart: string | undefined; timeEnd: string | undefined; - timeDimension: string | undefined; + timeDimension?: string | undefined; timeControlsReady: boolean | undefined; smallChip?: boolean; side?: "top" | "right" | "bottom" | "left"; diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.spec.ts b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.spec.ts deleted file mode 100644 index a4520e25a431..000000000000 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.spec.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { - addFilter, - useDashboardFetchMocksForComponentTests, -} from "@rilldata/web-common/features/dashboards/filters/test/filter-test-utils"; -import DimensionFilter from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilter.svelte"; -import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants"; -import { renderFilterComponent } from "@rilldata/web-common/features/dashboards/filters/test/render-filter-component"; -import { - createAndExpression, - createInExpression, - createLikeExpression, -} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import { - AD_BIDS_EXPLORE_INIT, - AD_BIDS_EXPLORE_NAME, - AD_BIDS_METRICS_INIT, - AD_BIDS_METRICS_NAME, - AD_BIDS_PUBLISHER_DIMENSION, -} from "@rilldata/web-common/features/dashboards/stores/test-data/data"; -import { mockAnimationsForComponentTesting } from "@rilldata/web-common/lib/test/mock-animations"; -import { - RUNTIME_CONTEXT_KEY, - RuntimeClient, -} from "@rilldata/web-common/runtime-client/v2"; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/svelte"; -import { get } from "svelte/store"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; - -// bits-ui 2.x Select uses PointerEvent APIs that jsdom doesn't support. -// Polyfill the missing types and methods so pointer-based interactions work in tests. -if (typeof globalThis.PointerEvent === "undefined") { - (globalThis as Record).PointerEvent = - class PointerEvent extends MouseEvent { - readonly pointerId: number; - readonly pointerType: string; - constructor( - type: string, - init?: PointerEventInit & Record, - ) { - super(type, init); - this.pointerId = (init?.pointerId as number) ?? 0; - this.pointerType = (init?.pointerType as string) ?? "mouse"; - } - }; -} -if (!HTMLElement.prototype.hasPointerCapture) { - HTMLElement.prototype.hasPointerCapture = () => false; -} -if (!HTMLElement.prototype.releasePointerCapture) { - HTMLElement.prototype.releasePointerCapture = () => {}; -} -if (!HTMLElement.prototype.scrollIntoView) { - HTMLElement.prototype.scrollIntoView = () => {}; -} - -/** - * bits-ui 2.x Select: open via keyboard (Space) on the trigger, then navigate - * with ArrowDown and select with Enter. Pointer events don't work reliably - * in jsdom because bits-ui's item ref tracking requires real layout. - */ -async function selectMode(name: RegExp) { - const trigger = document.getElementById("dimension-filter-mode-selector")!; - trigger.focus(); - // Open the select dropdown - await act(async () => { - await fireEvent.keyDown(trigger, { key: " " }); - }); - // Wait for options to appear - await waitFor(() => - expect(screen.getByRole("option", { name })).toBeVisible(), - ); - // bits-ui auto-highlights the current value on open. Navigate ArrowDown - // until the target option is highlighted, then press Enter to select. - const options = screen.getAllByRole("option"); - const targetIndex = options.findIndex((opt) => name.test(opt.textContent!)); - const highlightedOpt = options.findIndex((opt) => - opt.hasAttribute("data-highlighted"), - ); - const startIndex = highlightedOpt >= 0 ? highlightedOpt : -1; - const steps = - targetIndex >= startIndex - ? targetIndex - startIndex - : options.length - startIndex + targetIndex; - for (let i = 0; i < steps; i++) { - await act(async () => { - await fireEvent.keyDown(trigger, { key: "ArrowDown" }); - }); - } - await act(async () => { - await fireEvent.keyDown(trigger, { key: "Enter" }); - }); -} - -async function selectFirstMode() { - const trigger = document.getElementById("dimension-filter-mode-selector")!; - trigger.focus(); - await act(async () => { - await fireEvent.keyDown(trigger, { key: " " }); - }); - await waitFor(() => - expect(screen.getByRole("option", { name: /Select/ })).toBeVisible(), - ); - await act(async () => { - await fireEvent.keyDown(trigger, { key: "Home" }); - }); - await act(async () => { - await fireEvent.keyDown(trigger, { key: "Enter" }); - }); -} - -async function closeFilterMenu(label: string) { - await act(() => screen.getByLabelText(label).click()); - await waitFor(() => - expect(screen.queryByRole("menu")).not.toBeInTheDocument(), - ); -} - -async function waitForBodyScrollCleanup() { - await new Promise((resolve) => window.setTimeout(resolve, 30)); -} - -/** - * Returns the text content of the mode selector trigger button. - */ -function getModeSelectorText() { - return document.getElementById("dimension-filter-mode-selector")!.textContent; -} - -/** - * Returns the text of each item inside a DropdownMenu group, as an array. - * bits-ui 2.x renders items as adjacent elements without whitespace, so - * checking individual items is more reliable than toHaveTextContent. - */ -function getGroupItemTexts(groupLabel: string): string[] { - const group = screen.getByLabelText(groupLabel); - const items = group.querySelectorAll( - "[data-dropdown-menu-item], [data-dropdown-menu-checkbox-item]", - ); - return Array.from(items).map((el) => el.textContent?.trim() ?? ""); -} - -describe("DimensionFilter", () => { - mockAnimationsForComponentTesting(); - const mocks = useDashboardFetchMocksForComponentTests(); - - beforeAll(() => { - mocks.mockMetricsView(AD_BIDS_METRICS_NAME, AD_BIDS_METRICS_INIT); - mocks.mockMetricsExplore( - AD_BIDS_EXPLORE_NAME, - AD_BIDS_METRICS_INIT, - AD_BIDS_EXPLORE_INIT, - ); - }); - - afterAll(waitForBodyScrollCleanup); - - it("Select filter mode", async () => { - const { stateManagers } = renderFilterComponent(); - - // Add a filter pill for publisher - await addFilter("publisher"); - - // Once the pill is added and dropdown is open, select "Facebook" and "Google" - await waitFor(() => expect(screen.getByText("Facebook")).toBeVisible()); - await act(() => screen.getByText("Facebook").click()); - await act(() => screen.getByText("Google").click()); - - // Close the dropdown to apply the selections (Select mode applies on close) - await act(() => screen.getByLabelText("Open publisher filter").click()); - - // Assert that filters are now applied to the dashboard store - expect(get(stateManagers.dashboardStore).whereFilter).toEqual( - createAndExpression([ - createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Facebook", "Google"]), - ]), - ); - - // Reopen the dropdown - await act(() => screen.getByLabelText("Open publisher filter").click()); - - // Change the mode to "Contains" and enter a search term "oo" - await selectMode(/Contains/); - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "oo" }, - }), - ); - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "3 results", - ), - ); - // Pill changes to reflect the current state of the dropdown - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher Contains oo (3)", - ); - - // Close the dropdown. - await act(() => screen.getByLabelText("Open publisher filter").click()); - // "Contains" mode does not persist since Apply was not clicked - await waitFor(() => - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher Facebook +1 other", - ), - ); - - // Open the dropdown again - await act(() => screen.getByLabelText("Open publisher filter").click()); - // Switch to "In List" mode and enter a value "Facebook,Google,Apple" - await selectMode(/In List/); - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "Facebook,Google,Apple" }, - }), - ); - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "2 of 3 matched", - ), - ); - expect(getGroupItemTexts("publisher results")).toEqual([ - "Facebook", - "Google", - "Apple", - ]); - // Pill changes to reflect the current state of the dropdown - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher In list (2 of 3)", - ); - - // Close the dropdown - await act(() => screen.getByLabelText("Open publisher filter").click()); - // "In List" mode does not persist since Apply was not clicked - await waitFor(() => - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher Facebook +1 other", - ), - ); - }); - - it("persists exclude operator when adding select values and toggling exclude", async () => { - const { stateManagers } = renderFilterComponent(); - - await addFilter("publisher"); - - await waitFor(() => expect(screen.getByText("Facebook")).toBeVisible()); - await act(() => screen.getByText("Facebook").click()); - await act(() => screen.getByText("Google").click()); - await act(() => - fireEvent.click(screen.getByLabelText("Include exclude toggle")), - ); - - expect(screen.getByLabelText("Include exclude toggle")).toHaveAttribute( - "data-state", - "checked", - ); - - await act(() => screen.getByLabelText("Open publisher filter").click()); - - expect(get(stateManagers.dashboardStore).whereFilter).toEqual( - createAndExpression([ - createInExpression( - AD_BIDS_PUBLISHER_DIMENSION, - ["Facebook", "Google"], - true, - ), - ]), - ); - }); - - it("creates new select filters with the current exclude mode", async () => { - const toggleDimensionFilterMode = vi.fn().mockResolvedValue(undefined); - const toggleDimensionValueSelections = vi.fn().mockResolvedValue(undefined); - - render(DimensionFilter, { - props: { - filterData: { - name: AD_BIDS_PUBLISHER_DIMENSION, - label: "Publisher", - mode: DimensionFilterMode.Select, - dimensions: new Map([[AD_BIDS_METRICS_NAME, {}]]), - selectedValues: [], - isInclude: true, - }, - expressionMap: new Map(), - openOnMount: true, - timeStart: undefined, - timeEnd: undefined, - timeDimension: undefined, - timeControlsReady: true, - removeDimensionFilter: vi.fn(), - applyDimensionInListMode: vi.fn(), - toggleDimensionValueSelections, - applyDimensionContainsMode: vi.fn(), - toggleDimensionFilterMode, - }, - context: new Map([ - [ - RUNTIME_CONTEXT_KEY, - new RuntimeClient({ host: "http://localhost", instanceId: "test" }), - ], - ]), - }); - - await waitFor(() => expect(screen.getByText("Facebook")).toBeVisible()); - await act(() => screen.getByText("Facebook").click()); - await act(() => - fireEvent.click(screen.getByLabelText("Include exclude toggle")), - ); - await closeFilterMenu("Open publisher filter"); - - expect(toggleDimensionFilterMode).not.toHaveBeenCalled(); - expect(toggleDimensionValueSelections).toHaveBeenCalledWith( - AD_BIDS_PUBLISHER_DIMENSION, - ["Facebook"], - [AD_BIDS_METRICS_NAME], - undefined, - undefined, - true, - ); - }); - - it("Contains filter mode", async () => { - const { stateManagers } = renderFilterComponent(); - - // Add a filter pill for publisher - await addFilter("publisher"); - - // Change the mode to "Contains" - await selectMode(/Contains/); - // No results yet. - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "0 results", - ), - ); - expect(screen.getByLabelText("publisher results")).toHaveTextContent( - "no results", - ); - - // Enter a search text "oo" - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "oo" }, - }), - ); - // 3 results based on the mocked response. - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "3 results", - ), - ); - expect(getGroupItemTexts("publisher results")).toEqual([ - "Facebook", - "Google", - "Yahoo", - ]); - // Pill is updated as well. - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher Contains oo (3)", - ); - - // Apply to get the filter to take effect. - await act(() => screen.getByRole("button", { name: "Apply" }).click()); - - // Filter is added to the dashboard - expect(get(stateManagers.dashboardStore).whereFilter).toEqual( - createAndExpression([ - createLikeExpression(AD_BIDS_PUBLISHER_DIMENSION, "%oo%"), - ]), - ); - // Filter pill is persisted - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher Contains oo (3)", - ); - }); - - it("In-List filter mode using dropdown", async () => { - const { stateManagers } = renderFilterComponent(); - - // Add a filter pill for publisher - await addFilter("publisher"); - - // Change the mode to "In List" - await selectMode(/In List/); - // No results yet. - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "0 results", - ), - ); - expect(screen.getByLabelText("publisher results")).toHaveTextContent( - "no results", - ); - - // Enter a search term with commas - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "Facebook,Google,Apple" }, - }), - ); - // 2 of 3 results matched based on mocked response. - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "2 of 3 matched", - ), - ); - expect(getGroupItemTexts("publisher results")).toEqual([ - "Facebook", - "Google", - "Apple", - ]); - // Pill is updated as well. - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher In list (2 of 3)", - ); - - // Adding a comma at the end doesnt add an extra element - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "Facebook,Google,Apple," }, - }), - ); - // Same 2 of 3 matched results as before - await waitFor(() => - expect(screen.getByLabelText("publisher result count")).toHaveTextContent( - "2 of 3 matched", - ), - ); - expect(getGroupItemTexts("publisher results")).toEqual([ - "Facebook", - "Google", - "Apple", - ]); - - // Apply to get the filter to take effect. - await act(() => screen.getByRole("button", { name: "Apply" }).click()); - - // Filter is added to the dashboard - expect(get(stateManagers.dashboardStore).whereFilter).toEqual( - createAndExpression([ - createInExpression(AD_BIDS_PUBLISHER_DIMENSION, [ - "Facebook", - "Google", - "Apple", - ]), - ]), - ); - expect( - get(stateManagers.dashboardStore).dimensionsWithInlistFilter, - ).toEqual(["publisher"]); - // Filter pill is persisted - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher In list (2 of 3)", - ); - }); - - it("Select filter mode treats comma search text literally", async () => { - renderFilterComponent(); - - // Add a filter pill for publisher - await addFilter("publisher"); - - // Enter search text with commas. - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "Facebook,Google,Apple" }, - }), - ); - - // Select mode should not auto-switch to In List. - expect(getModeSelectorText()).toContain("Select"); - expect( - screen.queryByLabelText("publisher result count"), - ).not.toBeInTheDocument(); - await waitFor(() => - expect(screen.getByLabelText("Open publisher filter")).toHaveTextContent( - "publisher", - ), - ); - }); - - it("checks In List URL length using selected and searched values", async () => { - const isUrlTooLongAfterInListFilter = vi.fn(() => false); - - render(DimensionFilter, { - props: { - filterData: { - name: AD_BIDS_PUBLISHER_DIMENSION, - label: AD_BIDS_PUBLISHER_DIMENSION, - mode: DimensionFilterMode.InList, - dimensions: new Map([[AD_BIDS_METRICS_NAME, {}]]), - selectedValues: ["Existing"], - }, - expressionMap: new Map(), - openOnMount: false, - timeStart: undefined, - timeEnd: undefined, - timeDimension: undefined, - timeControlsReady: false, - removeDimensionFilter: vi.fn(), - applyDimensionInListMode: vi.fn(), - toggleDimensionValueSelections: vi.fn(), - applyDimensionContainsMode: vi.fn(), - toggleDimensionFilterMode: vi.fn(), - isUrlTooLongAfterInListFilter, - }, - context: new Map([ - [ - RUNTIME_CONTEXT_KEY, - new RuntimeClient({ host: "http://localhost", instanceId: "test" }), - ], - ]), - }); - - await act(() => screen.getByLabelText("Open publisher filter").click()); - await act(() => - fireEvent.input(screen.getByLabelText("publisher search list"), { - target: { value: "Facebook,Google" }, - }), - ); - - expect(isUrlTooLongAfterInListFilter).toHaveBeenLastCalledWith([ - "Existing", - "Facebook", - "Google", - ]); - - // Close the dropdown so bits-ui's body-scroll-lock cleanup fires while - // jsdom is still alive (otherwise the deferred cleanup fires after teardown - // and produces an unhandled "document is not defined" error). - await act(() => screen.getByLabelText("Open publisher filter").click()); - }); - - it("commits Select mode when leaving In List without value changes", async () => { - const toggleDimensionValueSelections = vi.fn().mockResolvedValue(undefined); - - render(DimensionFilter, { - props: { - filterData: { - name: AD_BIDS_PUBLISHER_DIMENSION, - label: "Publisher", - mode: DimensionFilterMode.InList, - dimensions: new Map([[AD_BIDS_METRICS_NAME, {}]]), - selectedValues: ["Facebook"], - isInclude: true, - }, - expressionMap: new Map(), - openOnMount: false, - timeStart: undefined, - timeEnd: undefined, - timeDimension: undefined, - timeControlsReady: false, - removeDimensionFilter: vi.fn(), - applyDimensionInListMode: vi.fn(), - toggleDimensionValueSelections, - applyDimensionContainsMode: vi.fn(), - toggleDimensionFilterMode: vi.fn(), - }, - context: new Map([ - [ - RUNTIME_CONTEXT_KEY, - new RuntimeClient({ host: "http://localhost", instanceId: "test" }), - ], - ]), - }); - - await act(() => screen.getByLabelText("Open publisher filter").click()); - await selectFirstMode(); - await waitFor(() => expect(getModeSelectorText()).toContain("Select")); - await closeFilterMenu("Open publisher filter"); - - expect(toggleDimensionValueSelections).toHaveBeenCalledWith( - AD_BIDS_PUBLISHER_DIMENSION, - [], - [AD_BIDS_METRICS_NAME], - undefined, - undefined, - false, - ); - }); - - it("rerenders the chip when only the include/exclude operator changes", async () => { - const props = { - filterData: { - name: "device_type", - label: "Device Type", - mode: DimensionFilterMode.Select, - dimensions: new Map([[AD_BIDS_METRICS_NAME, {}]]), - selectedValues: ["ConnectedTV"], - isInclude: false, - }, - expressionMap: new Map(), - openOnMount: false, - timeStart: undefined, - timeEnd: undefined, - timeDimension: undefined, - timeControlsReady: false, - removeDimensionFilter: vi.fn(), - applyDimensionInListMode: vi.fn(), - toggleDimensionValueSelections: vi.fn(), - applyDimensionContainsMode: vi.fn(), - toggleDimensionFilterMode: vi.fn(), - }; - - const { rerender } = render(DimensionFilter, { - props, - context: new Map([ - [ - RUNTIME_CONTEXT_KEY, - new RuntimeClient({ host: "http://localhost", instanceId: "test" }), - ], - ]), - }); - - const chip = screen.getByLabelText("device_type filter"); - expect(screen.getByLabelText("Open device_type filter")).toHaveTextContent( - "Exclude Device Type ConnectedTV", - ); - expect(chip).toHaveClass("exclude"); - - await rerender({ - ...props, - filterData: { - ...props.filterData, - isInclude: true, - }, - }); - - await waitFor(() => - expect( - screen.getByLabelText("Open device_type filter"), - ).toHaveTextContent("Device Type ConnectedTV"), - ); - expect( - screen.getByLabelText("Open device_type filter"), - ).not.toHaveTextContent("Exclude"); - expect(chip).not.toHaveClass("exclude"); - }); - - it("applies a pending include/exclude toggle when select mode closes", async () => { - const toggleDimensionFilterMode = vi.fn().mockResolvedValue(undefined); - const toggleDimensionValueSelections = vi.fn().mockResolvedValue(undefined); - const props = { - filterData: { - name: AD_BIDS_PUBLISHER_DIMENSION, - label: "Publisher", - mode: DimensionFilterMode.Select, - dimensions: new Map([[AD_BIDS_METRICS_NAME, {}]]), - selectedValues: ["Facebook"], - isInclude: true, - }, - expressionMap: new Map(), - openOnMount: false, - timeStart: undefined, - timeEnd: undefined, - timeDimension: undefined, - timeControlsReady: false, - removeDimensionFilter: vi.fn(), - applyDimensionInListMode: vi.fn(), - toggleDimensionValueSelections, - applyDimensionContainsMode: vi.fn(), - toggleDimensionFilterMode, - }; - - render(DimensionFilter, { - props, - context: new Map([ - [ - RUNTIME_CONTEXT_KEY, - new RuntimeClient({ host: "http://localhost", instanceId: "test" }), - ], - ]), - }); - - await act(() => screen.getByLabelText("Open publisher filter").click()); - await act(() => - fireEvent.click(screen.getByLabelText("Include exclude toggle")), - ); - expect(screen.getByLabelText("Include exclude toggle")).toHaveAttribute( - "data-state", - "checked", - ); - await act(() => screen.getByLabelText("Open publisher filter").click()); - - await waitFor(() => - expect(toggleDimensionFilterMode).toHaveBeenCalledWith( - AD_BIDS_PUBLISHER_DIMENSION, - [AD_BIDS_METRICS_NAME], - ), - ); - expect(toggleDimensionValueSelections).not.toHaveBeenCalled(); - }); -}); diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte deleted file mode 100644 index 87fe704e2545..000000000000 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte +++ /dev/null @@ -1,670 +0,0 @@ - - - { - if (e.key === "Enter") { - await onApply(); - } - }} -/> - - - - {#snippet child({ props })} - - removeDimensionFilter(name, metricsViewNames)} - removable={!readOnly && !curPinned && !required} - {readOnly} - removeTooltipText="remove {selectedValues.length} value{selectedValues.length !== - 1 - ? 's' - : ''}" - > - - -
- - - {name} - {required - ? "required dimension" - : "dimension"} - - {#if missingRequired} - This filter is required. Select a value to load the dashboard. - {:else} - Click to edit the filters in this dimension - {/if} - -
-
- {/snippet} -
- - - -
- {#if toggleFilterPin || toggleFilterRequired} -
- {label} - -
- {#if toggleFilterRequired} - { - curRequired = !curRequired; - }} - /> - {/if} - {#if toggleFilterPin} - { - curPinned = !curPinned; - }} - /> - {/if} -
-
- {/if} -
- - -
- {#if showExtraInfo} -
- {#if curMode !== DimensionFilterMode.Select} - - {searchResultCountText} - - {:else} -
- {/if} -
- {/if} -
- - {#if showExtraInfo} - - {/if} - -
- {#if isFetching} -
- -
- {:else if error} -
error
- {:else if inListTooLong} -
- List is too long. Please remove some values. -
- {:else if correctedSearchResults} - - - {#if curMode === DimensionFilterMode.Select && !curSearchText} - {#each checkedItems as name (name)} - {@const selected = effectiveSelectedValues.includes(name)} - {@const label = name ?? "null"} - - handleItemClick(name)} - > - - {#if label.length > 240} - {label.slice(0, 240)}... - {:else} - {label} - {/if} - - - {/each} - {/if} - - - {#if curMode === DimensionFilterMode.Select && !curSearchText && checkedItems.length > 0 && uncheckedItems.length > 0} - - {/if} - - - {#each uncheckedItems as name (name)} - {@const selected = effectiveSelectedValues.includes(name)} - {@const label = name ?? "null"} - - handleItemClick(name)} - > - - {#if label.length > 240} - {label.slice(0, 240)}... - {:else} - {label} - {/if} - - - {/each} - - - {#if uncheckedItems.length === 0 && (curMode !== DimensionFilterMode.Select || checkedItems.length === 0)} -
- no results -
- {/if} -
- {/if} -
- - -
-
diff --git a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte deleted file mode 100644 index abf95db34215..000000000000 --- a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - - { - if (open && pinned !== curPinned) { - toggleFilterPin?.(name, metricsViewNames); - } - if (open && required !== curRequired) { - toggleFilterRequired?.(name, metricsViewNames); - } - }} -> - - {#snippet child({ props })} - - - { - return d.name === dimensionName; - })?.displayName ?? ""} - {filter} - {label} - slot="body" - /> - -
- - - {name} - {required - ? m.dashboard_required_measure() - : label || ""} - - - {#if missingRequired} - {m.dashboard_filter_required_set_value()} - {:else} - {m.dashboard_click_to_edit_values()} - {/if} - -
-
- {/snippet} -
- - {#if open} - { - if (pinned !== curPinned) { - toggleFilterPin?.(name, metricsViewNames); - } - if (required !== curRequired) { - toggleFilterRequired?.(name, metricsViewNames); - } - onApply(params); - }} - bind:pinned={curPinned} - bind:required={curRequired} - showPinControl={!!toggleFilterPin} - showRequiredControl={!!toggleFilterRequired} - {side} - /> - {/if} -
diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 1c5cd2bef888..c906a038a327 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -483,6 +483,7 @@ measure={screenshotDialogMeasure} metricsViewName={chartMetricsViewName} tddChartType={tddChartType ?? TDDChart.DEFAULT} + {expressionFilterManager} where={chartWhere} {timeDimension} {timeStart} diff --git a/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte b/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte index 63633f29b351..e45f866fc6b0 100644 --- a/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte +++ b/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte @@ -13,15 +13,17 @@ import MeasureChart from "./measure-chart/MeasureChart.svelte"; import MeasureChartXAxis from "./measure-chart/MeasureChartXAxis.svelte"; import { prettyFormatTimeRange } from "@rilldata/web-common/lib/time/ranges/formatter.ts"; - import ExploreFilterChipsReadOnly from "@rilldata/web-common/features/dashboards/filters/ExploreFilterChipsReadOnly.svelte"; import ThemeProvider from "@rilldata/web-common/features/dashboards/ThemeProvider.svelte"; import { activeDashboardTheme } from "@rilldata/web-common/features/themes/active-dashboard-theme.ts"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; + import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; + import ReadonlyExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ReadonlyExpressionFilters.svelte"; export let open = false; export let measure: MetricsViewSpecMeasure; export let metricsViewName: string; export let where: V1Expression | undefined = undefined; + export let expressionFilterManager: ExpressionFilterManager; export let tddChartType: TDDChart = TDDChart.DEFAULT; export let timeDimension: string | undefined = undefined; export let timeStart: string | undefined = undefined; @@ -123,12 +125,7 @@
- +
{#if timeGranularity} diff --git a/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte b/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte index 83055e782248..8965350c3017 100644 --- a/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte +++ b/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte @@ -1,17 +1,19 @@
{/if} - {#if hasFilters} - + {#if expressionFilterManager.hasSomeFilter} + {/if}
From f638ed4bc89ae8a02c72d92e023d0050a0cff5f5 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Tue, 4 Aug 2026 12:33:30 +0530 Subject: [PATCH 11/21] Advanced filters and pivot-click-to-filter --- .../canvas/CanvasDashboardWrapper.svelte | 92 +++--- .../src/features/canvas/chat-context.ts | 4 +- .../canvas/components/BaseCanvasComponent.ts | 196 ++++++------- .../components/SaveDefaultsButton.svelte | 75 ++++- .../leaderboard/LeaderboardDisplay.svelte | 29 +- .../canvas/components/markdown/util.ts | 31 +- .../pivot/CanvasPivotRenderer.svelte | 12 +- .../pivot/pivot-click-to-filter.spec.ts | 268 +++++++++--------- .../components/pivot/pivot-click-to-filter.ts | 46 +-- .../components/pivot/pivot-test-data.ts | 108 +++++++ .../features/canvas/stores/canvas-entity.ts | 54 ---- .../dashboards/dashboard-fetch-mocks.ts | 33 ++- .../filters/CanvasFilterButton.svelte | 114 -------- .../filters/ExpressionFilterManager.svelte.ts | 99 ++++--- .../filters/ExpressionFilters.svelte | 2 - .../MissingRequiredFiltersMessage.svelte | 73 +++++ .../filters/ReadonlyExpressionFilters.svelte | 9 +- .../advanced-filters/AddAdvancedFilter.svelte | 132 +++++++++ .../AdvancedFilterInput.svelte | 84 ++++++ .../AdvancedFilterPill.svelte | 71 +++++ .../advanced-filters/advanced-filter.ts | 29 ++ .../advanced-filters/plugins.svelte.ts | 202 +++++++++++++ .../DimensionFilter.svelte | 12 +- .../ReadonlyDimensionFilter.svelte | 10 +- .../measure-filters-v2/MeasureFilter.svelte | 4 +- .../ReadonlyMeasureFilter.svelte | 10 +- .../src/features/dashboards/filters/utils.ts | 13 + .../providers/YAMLConfigProvider.svelte.ts | 3 +- .../providers/MetricsViewsProvider.spec.ts | 80 ++++++ .../test/RuntimeContextHarness.svelte | 34 +++ .../test/metrics-views-test-utils.svelte.ts | 156 ++++++++++ web-common/src/lib/arrayUtils.ts | 8 +- 32 files changed, 1477 insertions(+), 616 deletions(-) create mode 100644 web-common/src/features/canvas/components/pivot/pivot-test-data.ts delete mode 100644 web-common/src/features/dashboards/filters/CanvasFilterButton.svelte create mode 100644 web-common/src/features/dashboards/filters/MissingRequiredFiltersMessage.svelte create mode 100644 web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte create mode 100644 web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte create mode 100644 web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte create mode 100644 web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts create mode 100644 web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts create mode 100644 web-common/src/features/dashboards/filters/utils.ts create mode 100644 web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts create mode 100644 web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte create mode 100644 web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index cab84a40e072..ac6b0f420da4 100644 --- a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte +++ b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte @@ -2,39 +2,59 @@ import { dynamicHeight } from "@rilldata/web-common/layout/layout-settings.ts"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import CellInspector from "@rilldata/web-common/components/CellInspector.svelte"; - import WarningIcon from "@rilldata/web-common/components/icons/WarningIcon.svelte"; import CanvasFilters from "./filters/CanvasFilters.svelte"; import { getCanvasStore } from "./state-managers/state-managers"; import ThemeProvider from "../dashboards/ThemeProvider.svelte"; import CanvasPdfExportView from "../exports/pdf/CanvasPdfExportView.svelte"; + import { getMissingRequiredFilters } from "@rilldata/web-common/features/dashboards/filters/utils.ts"; + import MissingRequiredFiltersMessage from "@rilldata/web-common/features/dashboards/filters/MissingRequiredFiltersMessage.svelte"; + import type { Snippet } from "svelte"; - const client = useRuntimeClient(); + const runtimeClient = useRuntimeClient(); + let instanceId = $derived(runtimeClient.instanceId); - export let maxWidth: number; - export let clientWidth = 0; - export let showGrabCursor = false; - export let filtersEnabled: boolean | undefined; - export let canvasName: string; - export let embedded: boolean = false; - export let builder = false; - export let onClick: () => void = () => {}; + let { + children, + canvasName, + maxWidth, + clientWidth = 0, + showGrabCursor = false, + filtersEnabled = undefined, + embedded = false, + builder = false, + onClick = () => {}, + }: { + children: Snippet; + canvasName: string; + maxWidth: number; + clientWidth?: number; + showGrabCursor?: boolean; + filtersEnabled?: boolean | undefined; + embedded?: boolean; + builder?: boolean; + onClick?: () => void; + } = $props(); - let contentRect = new DOMRectReadOnly(0, 0, 0, 0); + let contentRect = $state(new DOMRectReadOnly(0, 0, 0, 0)); - $: ({ instanceId } = client); - - $: ({ + let { canvasEntity: { theme, exportMode, - filterManager: { missingRequiredFiltersStore }, + expressionFilterManager, + metricsViewsProvider, + yamlConfigProvider, }, - } = getCanvasStore(canvasName, instanceId)); + } = $derived(getCanvasStore(canvasName, instanceId)); - $: missingRequiredFilters = $missingRequiredFiltersStore; - $: hasMissingRequired = missingRequiredFilters.length > 0; + let missingRequiredFilters = $derived( + getMissingRequiredFilters(expressionFilterManager, yamlConfigProvider), + ); + let hasMissingRequired = $derived(missingRequiredFilters.length > 0); - $: ({ width: clientWidth } = contentRect); + $effect(() => { + clientWidth = contentRect.width; + }); @@ -87,34 +107,10 @@ }} > {#if hasMissingRequired} -
- -
+ {:else}
- + {@render children()}
{/if}
diff --git a/web-common/src/features/canvas/chat-context.ts b/web-common/src/features/canvas/chat-context.ts index 02c0b8a9807c..ea82bfa0018f 100644 --- a/web-common/src/features/canvas/chat-context.ts +++ b/web-common/src/features/canvas/chat-context.ts @@ -41,7 +41,7 @@ function getActiveCanvasContext( const canvasFiltersStore = derived( [ - canvasStore.canvasEntity.filterManager.filterMapStore, + canvasStore.canvasEntity.expressionFilterManager.exprByMetricsViewStore, canvasStore.canvasEntity.timeManager.state.interval, ], ([filtersMap, selectedInterval]) => { @@ -64,7 +64,7 @@ function getActiveCanvasContext( if (filtersMap.size) { analystAgentContext.wherePerMetricsView = {}; - filtersMap.forEach((expr, mv) => { + Object.entries(filtersMap).forEach(([mv, expr]) => { if (expr.cond?.exprs?.length) { analystAgentContext.wherePerMetricsView![mv] = expr; } diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index 11c89e08dbe0..640d94f941ee 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -19,8 +19,8 @@ import type { Readable, Unsubscriber } from "svelte/store"; import { derived, get, writable, type Writable } from "svelte/store"; import { mergeFilters } from "../../dashboards/pivot/pivot-merge-filters"; import { - buildValidMetricsViewFilter, createAndExpression, + sanitiseExpression, } from "../../dashboards/stores/filter-utils"; import type { ComparisonTimeRangeState, @@ -199,36 +199,31 @@ export abstract class BaseCanvasComponent { this.localTimeControls.grainStore, this.localTimeControls.comparisonRangeStore, this.localTimeControls.rangeStore, - this.parent.filterManager.metricsViewFilters, - this.parent.specStore, + this.parent.expressionFilterManager.exprByMetricsViewStore, this.parent.timeManager.hasTimeSeriesMap, this.specStore, ], - ( - [ - globalInterval, - globalGrainStore, - globalShowTimeComparison, - globalComparisonRange, - globalComparisonInterval, - timeZone, - globalRange, - localInterval, - localComparisonInterval, - localShowTimeComparison, - localGrainStore, - localComparisonRange, - localRange, - metricsViewFilters, - canvasData, - hasTimeSeriesMap, - componentSpec, - ], - set, - ) => { + ([ + globalInterval, + globalGrainStore, + globalShowTimeComparison, + globalComparisonRange, + globalComparisonInterval, + timeZone, + globalRange, + localInterval, + localComparisonInterval, + localShowTimeComparison, + localGrainStore, + localComparisonRange, + localRange, + metricsViewFilters, + hasTimeSeriesMap, + componentSpec, + ]) => { const hasTimeSeries = hasTimeSeriesMap.get(this.metricsViewName); - const mvFilters = metricsViewFilters.get(this.metricsViewName); + const mvFilters = metricsViewFilters[this.metricsViewName]; let timeGrain = globalGrainStore; @@ -271,7 +266,7 @@ export abstract class BaseCanvasComponent { }; if (!mvFilters) { - set({ + return { timeRange: timeRange, where: undefined, timeGrain: timeGrain, @@ -280,90 +275,77 @@ export abstract class BaseCanvasComponent { timeRangeState: timeRangeState, comparisonTimeRangeState: comparisonTimeRangeState, showTimeComparison: showTimeComparison, - }); - return; + }; } - derived([mvFilters.parsed], ([parsedFilter]) => { - const metricsViewName = componentSpec["metrics_view"]; - const metricsView = canvasData.data?.metricsViews?.[metricsViewName]; - const dimensions = metricsView?.state?.validSpec?.dimensions ?? []; - const measures = metricsView?.state?.validSpec?.measures ?? []; - - if (componentSpec?.["time_filters"]) { - timeRange = { - start: localInterval?.start.toISO(), - end: localInterval?.end.toISO(), - timeZone, - }; - - comparisonTimeRange = { - start: localComparisonInterval?.start.toISO(), - end: localComparisonInterval?.end.toISO(), - timeZone, - }; - - showTimeComparison = localShowTimeComparison; - - timeGrain = localGrainStore ?? globalGrainStore; - - const localTimeRangeState: TimeRangeState = { - selectedTimeRange: localInterval - ? { - name: localRange ?? TimeRangePreset.CUSTOM, - start: localInterval.start.toJSDate(), - end: localInterval.end.toJSDate(), - interval: localGrainStore ?? globalGrainStore, - } - : undefined, - timeStart: localInterval?.start.toISO(), - timeEnd: localInterval?.end.toISO(), - }; - const localComparisonRangeState: - | ComparisonTimeRangeState - | undefined = localComparisonInterval && { - comparisonTimeStart: localComparisonInterval.start.toISO(), - comparisonTimeEnd: localComparisonInterval.end.toISO(), - selectedComparisonTimeRange: { - start: localComparisonInterval.start.toJSDate(), - end: localComparisonInterval.end.toJSDate(), - name: localComparisonRange, - }, - }; - - timeRangeState = localTimeRangeState; - comparisonTimeRangeState = localComparisonRangeState; - } - - // Dimension Filters - const globalWhere = - buildValidMetricsViewFilter( - parsedFilter.dimensionFilter, - parsedFilter.dimensionThresholdFilters, - dimensions, - measures, - ) ?? createAndExpression([]); - - let where: V1Expression | undefined = globalWhere; - - if (componentSpec?.["dimension_filters"]) { - const { expr: componentWhere } = getFiltersFromText( - componentSpec?.["dimension_filters"] as string, - ); - where = mergeFilters(globalWhere, componentWhere); - } + if (componentSpec?.["time_filters"]) { + timeRange = { + start: localInterval?.start.toISO(), + end: localInterval?.end.toISO(), + timeZone, + }; + + comparisonTimeRange = { + start: localComparisonInterval?.start.toISO(), + end: localComparisonInterval?.end.toISO(), + timeZone, + }; - return { - timeRange, - showTimeComparison, - comparisonTimeRange, - where, - timeGrain, - timeRangeState, - comparisonTimeRangeState, - hasTimeSeries, + showTimeComparison = localShowTimeComparison; + + timeGrain = localGrainStore ?? globalGrainStore; + + const localTimeRangeState: TimeRangeState = { + selectedTimeRange: localInterval + ? { + name: localRange ?? TimeRangePreset.CUSTOM, + start: localInterval.start.toJSDate(), + end: localInterval.end.toJSDate(), + interval: localGrainStore ?? globalGrainStore, + } + : undefined, + timeStart: localInterval?.start.toISO(), + timeEnd: localInterval?.end.toISO(), + }; + const localComparisonRangeState: + | ComparisonTimeRangeState + | undefined = localComparisonInterval && { + comparisonTimeStart: localComparisonInterval.start.toISO(), + comparisonTimeEnd: localComparisonInterval.end.toISO(), + selectedComparisonTimeRange: { + start: localComparisonInterval.start.toJSDate(), + end: localComparisonInterval.end.toJSDate(), + name: localComparisonRange, + }, }; - }).subscribe(set); + + timeRangeState = localTimeRangeState; + comparisonTimeRangeState = localComparisonRangeState; + } + + // Dimension Filters + const globalWhere = + sanitiseExpression(mvFilters, undefined) ?? createAndExpression([]); + + let where: V1Expression | undefined = globalWhere; + + if (componentSpec?.["dimension_filters"]) { + const { expr: componentWhere } = getFiltersFromText( + componentSpec?.["dimension_filters"] as string, + ); + where = mergeFilters(globalWhere, componentWhere); + } + + return { + timeRange, + showTimeComparison, + comparisonTimeRange, + where, + timeGrain, + timeRangeState, + comparisonTimeRangeState, + hasTimeSeries, + }; }, ); } diff --git a/web-common/src/features/canvas/components/SaveDefaultsButton.svelte b/web-common/src/features/canvas/components/SaveDefaultsButton.svelte index 50aec2658190..66f7b678af6c 100644 --- a/web-common/src/features/canvas/components/SaveDefaultsButton.svelte +++ b/web-common/src/features/canvas/components/SaveDefaultsButton.svelte @@ -5,22 +5,77 @@ import LeaderboardIcon from "../icons/LeaderboardIcon.svelte"; import CheckCircleNew from "@rilldata/web-common/components/icons/CheckCircleNew.svelte"; import LoadingSpinner from "@rilldata/web-common/components/icons/LoadingSpinner.svelte"; + import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; + import { arrayUnorderedEquals } from "@rilldata/web-common/lib/arrayUtils.ts"; + import { page } from "$app/state"; - export let canvasName: string; - export let instanceId: string; - export let saving: boolean; + let { + canvasName, + instanceId, + saving, + }: { + canvasName: string; + instanceId: string; + saving: boolean; + } = $props(); - let justClickedSaveAsDefault = false; + let justClickedSaveAsDefault = $state(false); - $: ({ - canvasEntity: { _rows, saveDefaultFilters, viewingDefaultsStore }, - } = getCanvasStore(canvasName, instanceId)); + let { + canvasEntity: { + _rows, + saveDefaultFilters, + defaultUrlParamsStore, + yamlConfigProvider, + }, + } = $derived(getCanvasStore(canvasName, instanceId)); - $: viewingDefaults = $viewingDefaultsStore; + let defaultUrlParams = $derived($defaultUrlParamsStore); + let curUrlParams = $derived(page.url.searchParams); - $: rows = $_rows; + let viewingDefaults = $derived.by(() => { + if ( + !arrayUnorderedEquals( + Object.keys(yamlConfigProvider.specPinnedFilters), + Object.keys(yamlConfigProvider.pinnedFilters), + ) + ) { + return false; + } + if ( + !arrayUnorderedEquals( + Object.keys(yamlConfigProvider.specRequiredFilters), + Object.keys(yamlConfigProvider.requiredFilters), + ) + ) { + return false; + } + if (defaultUrlParams.size === 0) { + return false; + } - $: canvasIsEmpty = rows.length === 0; + for (const [key, value] of defaultUrlParams.entries()) { + if (curUrlParams.get(key) !== value) { + // Ignore time range if not set + if ( + curUrlParams.get(key) === null && + key === ExploreStateURLParams.TimeRange + ) { + continue; + } + return false; + } + } + for (const [key, value] of curUrlParams.entries()) { + if (defaultUrlParams.get(key) !== value) { + return false; + } + } + return true; + }); + + let rows = $derived($_rows); + let canvasIsEmpty = $derived(rows.length === 0); - {m.dashboard_add_filter()} - - {/snippet} - - - { - setTemporaryFilterName(name); - }} - {selectableGroups} - selectedItems={[]} - {side} - /> - - - diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index b2f34d8dd69c..1e62f6b16708 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -18,6 +18,7 @@ import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/f 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import { writable, type Writable } from "svelte/store"; type FilterManager = MeasureFilterManager | DimensionFilterManager; type ParsedUrlParamsByMV = Record< @@ -34,14 +35,17 @@ type ParsedUrlParamsByMV = Record< * from the filter menu, and the ones created by a chart interaction. */ export class ExpressionFilterManager { - /** Filter param the managers are derived from. */ + // Filter param the managers are derived from. public exprParam: string = $state(""); - /** Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. */ + // Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. public temporaryFilterName: string | undefined = $state(undefined); - /** True when the param cannot be represented as chips. */ + // True when the param cannot be represented as chips. public isComplexFilter: boolean; public exprByMetricsView: Record; + // Temporary svelte4 store for legacy components + public exprByMetricsViewStore: Writable> = + writable({}); public hasSomeFilter: boolean; public filterManagers: { @@ -50,6 +54,7 @@ export class ExpressionFilterManager { }; public readonly filterManagersMap: Record; + // Raw parsed expression from url params. Validated expression is stored in exprByMetricsView. private prevUrlParams = $state(""); private parsedUrlParams = $state({}); @@ -77,6 +82,10 @@ export class ExpressionFilterManager { this.hasSomeFilter = $derived( Object.keys(this.exprByMetricsView).length > 0, ); + + $effect(() => { + this.exprByMetricsViewStore.set(this.exprByMetricsView); + }); } public setUrlParams = (searchParams: URLSearchParams) => { @@ -107,14 +116,22 @@ export class ExpressionFilterManager { }; public setExprForMetricsView(mvName: string, expr: V1Expression | undefined) { + const prevURLSearch = new URLSearchParams(this.prevUrlParams); + const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; const filterParam = expr ? convertExpressionToFilterParam(expr) : ""; - this.setUrlParams(new URLSearchParams(`${paramKey}=${filterParam}`)); + prevURLSearch.set(paramKey, filterParam); + + this.setUrlParams(prevURLSearch); } public setParamForMetricsView(mvName: string, param: string) { + const prevURLSearch = new URLSearchParams(this.prevUrlParams); + const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; - this.setUrlParams(new URLSearchParams(`${paramKey}=${param}`)); + prevURLSearch.set(paramKey, param); + + this.setUrlParams(prevURLSearch); } /** Adds a chip for a filter that has no value yet. The chip opens as soon as it is rendered. */ @@ -179,6 +196,40 @@ export class ExpressionFilterManager { ); } + public createDimensionOrMeasureManagerForName(name: string) { + return ( + this.createDimensionFilterManager(name) ?? + this.createMeasureFilterManager(name) + ); + } + + public createDimensionOrMeasureManagerForExpr( + expr: V1Expression, + dimensionsWithInlistFilter: string[] = [], + ) { + const ident = expr?.cond?.exprs?.[0]?.ident ?? ""; + // Both filter types are keyed off a dimension: a dimension filter by the dimension it filters + // and a measure filter by the dimension its subquery groups by. + if (!this.metricsViewsProvider.dimensionSpecs[ident]) return undefined; + + const firstValueExpr = expr?.cond?.exprs?.[1]; + + if (firstValueExpr?.subquery) { + // Having a subquery means this is a measure filter. + const measureName = firstValueExpr.subquery.measures?.[0]; + if (!measureName) return undefined; + + return this.createMeasureFilterManager(measureName, firstValueExpr); + } else { + // Everything else is a dimension filter for now. + return this.createDimensionFilterManager( + ident, + expr, + dimensionsWithInlistFilter.includes(ident), + ); + } + } + /** Managers for the filters in the param, keyed by name and in param order. */ private buildParamFilterManagers() { const measuresMap = new Map(); @@ -192,32 +243,15 @@ export class ExpressionFilterManager { if (!expr) return; forEachIdentifier(expr, (e, ident) => { - // Both filter types are keyed off a dimension: a dimension filter by the dimension it filters - // and a measure filter by the dimension its subquery groups by. - if (!this.metricsViewsProvider.dimensionSpecs[ident]) return; - - const firstValueExpr = e?.cond?.exprs?.[1]; - - if (firstValueExpr?.subquery) { - // Having a subquery means this is a measure filter. - const measureName = firstValueExpr.subquery.measures?.[0]; - if (!measureName) return; - - const measureFilterManager = this.createMeasureFilterManager( - measureName, - firstValueExpr, - ); - if (measureFilterManager) - measuresMap.set(measureName, measureFilterManager); - } else { - // Everything else is a dimension filter for now. - const dimensionFilterManager = this.createDimensionFilterManager( - ident, - e, - dimensionsWithInlistFilter.includes(ident), - ); - if (dimensionFilterManager) - dimensionsMap.set(ident, dimensionFilterManager); + const manager = this.createDimensionOrMeasureManagerForExpr( + e, + dimensionsWithInlistFilter, + ); + if (!manager) return; + if (manager instanceof DimensionFilterManager) { + dimensionsMap.set(ident, manager); + } else if (manager instanceof MeasureFilterManager) { + measuresMap.set(ident, manager); } }); }, @@ -247,8 +281,7 @@ export class ExpressionFilterManager { const filterManager = dimensionsMap.get(name) ?? measuresMap.get(name) ?? - this.createDimensionFilterManager(name) ?? - this.createMeasureFilterManager(name); + this.createDimensionOrMeasureManagerForName(name); if (!filterManager) return; added.add(name); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/ExpressionFilters.svelte index dcb198aec60a..38b9e1dd7c01 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/ExpressionFilters.svelte @@ -82,8 +82,6 @@ ); - -
{#if !hasFilters} diff --git a/web-common/src/features/dashboards/filters/MissingRequiredFiltersMessage.svelte b/web-common/src/features/dashboards/filters/MissingRequiredFiltersMessage.svelte new file mode 100644 index 000000000000..152f3df25e21 --- /dev/null +++ b/web-common/src/features/dashboards/filters/MissingRequiredFiltersMessage.svelte @@ -0,0 +1,73 @@ + + +
+ +
diff --git a/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte b/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte index 40b2494c24c2..d906fb194b4a 100644 --- a/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte @@ -16,6 +16,7 @@ hasBoldTimeRange = true, chipLayout = "wrap", ariaLabel = m.dashboard_readonly_filter_chips_aria(), + showPinned = false, }: { expressionFilterManager: ExpressionFilterManager; displayTimeRange?: V1TimeRange | undefined; @@ -25,19 +26,19 @@ hasBoldTimeRange?: boolean; chipLayout?: "wrap" | "scroll" | "col"; ariaLabel?: string | undefined; + showPinned?: boolean; } = $props(); let scrollContainer: HTMLDivElement; - // Readonly filters sections do not care about empty temporary, pinned or required filters. let nonEmptyDimensionManagers = $derived( expressionFilterManager.filterManagers.dimensions.filter( - (dimensionManager) => !!dimensionManager.expr, + (dimensionManager) => showPinned || !!dimensionManager.expr, ), ); let nonEmptyMeasureManager = $derived( expressionFilterManager.filterManagers.measures.filter( - (measureManager) => !!measureManager.expr, + (measureManager) => showPinned || !!measureManager.expr, ), ); @@ -74,6 +75,7 @@ yamlConfigProvider={expressionFilterManager.yamlConfigProvider} timeStart={queryTimeStart} timeEnd={queryTimeEnd} + {showPinned} />
{/each} @@ -83,6 +85,7 @@
{/each} diff --git a/web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte b/web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte new file mode 100644 index 000000000000..e36642e323e1 --- /dev/null +++ b/web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte @@ -0,0 +1,132 @@ + + +
+ +
+ + diff --git a/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte b/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte new file mode 100644 index 000000000000..e4596fb0c39e --- /dev/null +++ b/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte @@ -0,0 +1,84 @@ + + +
+
+ +
+ + diff --git a/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte b/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte new file mode 100644 index 000000000000..0d0d636328d0 --- /dev/null +++ b/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte @@ -0,0 +1,71 @@ + + +
+ {#if expressionFilterManager && dimOrMesManager} + {#if dimOrMesManager instanceof DimensionFilterManager} + + {:else if dimOrMesManager instanceof MeasureFilterManager} + + {/if} + {:else} + {advancedFilter.sql} + {/if} +
diff --git a/web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts b/web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts new file mode 100644 index 000000000000..229240618db0 --- /dev/null +++ b/web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts @@ -0,0 +1,29 @@ +import type { + MetricsViewSpecDimension, + MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client"; + +export const ADVANCED_FILTER_TAG = "advanced-filter"; + +export type AdvancedFilterMentionOption = + | { type: "dimension"; dimension: MetricsViewSpecDimension } + | { type: "measure"; measure: MetricsViewSpecMeasure }; + +export type AdvancedFilter = { + sql: string; + name: string; +}; + +export function convertAdvancedFilterToHTML(advancedFilter: AdvancedFilter) { + return `<${ADVANCED_FILTER_TAG} name="${advancedFilter.name}">${advancedFilter.sql}`; +} + +const htmlTagStartRegex = new RegExp( + `<${ADVANCED_FILTER_TAG} name=".*?">`, + "g", +); +const htmlTagEndRegex = new RegExp(``, "g"); + +export function convertHTMLToSql(html: string) { + return html.replace(htmlTagStartRegex, "").replace(htmlTagEndRegex, ""); +} diff --git a/web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts b/web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts new file mode 100644 index 000000000000..44deff90bf3d --- /dev/null +++ b/web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts @@ -0,0 +1,202 @@ +import { Mention, type MentionOptions } from "@tiptap/extension-mention"; +import { + ADVANCED_FILTER_TAG, + type AdvancedFilter, + type AdvancedFilterMentionOption, + convertAdvancedFilterToHTML, +} from "@rilldata/web-common/features/dashboards/filters/advanced-filters/advanced-filter.ts"; +import { getAllContexts, mount, unmount } from "svelte"; +import AdvancedFilterPill from "@rilldata/web-common/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte"; +import type { + MetricsViewSpecDimension, + MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client"; +import AddAdvancedFilter from "@rilldata/web-common/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte"; +import { Placeholder, UndoRedo } from "@tiptap/extensions"; +import type { EditorView } from "@tiptap/pm/view"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { parseInlineAttr } from "@rilldata/web-common/features/chat/core/context/inline-context.ts"; + +export function getAdvancedFilterEditorPlugins({ + expressionFilterManager, + dimensions, + measures, +}: { + expressionFilterManager: ExpressionFilterManager; + dimensions: MetricsViewSpecDimension[]; + measures: MetricsViewSpecMeasure[]; +}) { + const plugins = [ + Document, + Paragraph, + Text, + Placeholder.configure({ + placeholder: "Enter advanced filter. Press @ for dimensions/measures", + }), + configureAdvancedFilterTipTapExtension( + expressionFilterManager, + dimensions, + measures, + ), + UndoRedo, + ]; + + return plugins; +} + +type FilterPillOptions = MentionOptions< + AdvancedFilterMentionOption, + AdvancedFilter +> & { + expressionFilterManager: ExpressionFilterManager | undefined; + allParentContexts: Map; +}; + +const FilterPillPlugin = Mention.extend({ + addOptions() { + return { + ...((this.parent?.() ?? {}) as MentionOptions), + expressionFilterManager: undefined, + // These have to be configured for the extension to work + allParentContexts: new Map(), + }; + }, + + // Mapping for attributes. We need to map values in InlineChatContext to html attribute and vice-versa. + addAttributes() { + return { + name: createAttributeEntry("name"), + sql: createAttributeEntry("sql"), + }; + }, + + parseHTML() { + return [{ tag: ADVANCED_FILTER_TAG }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ADVANCED_FILTER_TAG, HTMLAttributes, ""]; + }, + + renderText({ node }) { + return convertAdvancedFilterToHTML(node.attrs as AdvancedFilter); + }, + + addNodeView() { + return ({ node, getPos, view, editor }) => { + // Create a wrapper div to render the component. + // We need this since svelte only takes a target wrapper. + const target = document.createElement("div"); + // We need this here to make sure the component is rendered inline. + target.className = "inline-block"; + + const { allParentContexts, expressionFilterManager } = this.options; + + // Create the inline chat context component. Pass the wrapper as the target. + const comp = mount(AdvancedFilterPill, { + target, + props: { + advancedFilter: node.attrs as AdvancedFilter, + expressionFilterManager, + onChange: (advancedFilter: AdvancedFilter) => { + const pos = getPos(); + if (!pos) return; + + // Dispatch a transaction to update the node attributes with the new context. + view.dispatch(getTransactionForFilter(advancedFilter, view, pos)); + editor.commands.focus(); + }, + focusEditor: () => editor.commands.focus(), + }, + context: allParentContexts, + }); + + return { + dom: target, + destroy() { + unmount(comp); + }, + }; + }; + }, +}); + +export function configureAdvancedFilterTipTapExtension( + expressionFilterManager: ExpressionFilterManager, + dimensions: MetricsViewSpecDimension[], + measures: MetricsViewSpecMeasure[], +) { + let comp: Record | null = null; + const pickerProps: Record = $state({}); + + const allParentContexts = getAllContexts(); + + return FilterPillPlugin.configure({ + expressionFilterManager, + allParentContexts, + suggestion: { + char: "@", + allowSpaces: true, + items: () => [ + ...dimensions.map( + (d) => + { type: "dimension", dimension: d }, + ), + ...measures.map( + (m) => { type: "measure", measure: m }, + ), + ], + render: () => ({ + onStart: (props) => { + if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment + + pickerProps.items = props.items; + pickerProps.refNode = props.decorationNode; + pickerProps.onSelect = (item: AdvancedFilter) => { + props.command(item); + }; + comp = mount(AddAdvancedFilter, { + target: document.body, + props: pickerProps, + context: allParentContexts, + }); + }, + + onUpdate(props) { + if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment + pickerProps.items = props.items; + pickerProps.refNode = props.decorationNode; + }, + + onExit: () => { + if (!comp) return; + unmount(comp); + comp = null; + }, + }), + }, + }); +} + +function getTransactionForFilter( + advancedFilter: AdvancedFilter, + view: EditorView, + pos: number, +) { + return view.state.tr + .setNodeAttribute(pos, "sql", advancedFilter.sql) + .setNodeAttribute(pos, "name", advancedFilter.name); +} + +function createAttributeEntry(key: string) { + return { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute(key) ?? // Parsing from html attribute. + parseInlineAttr(element.innerHTML, key) ?? // Parsing from inline prompt. + null, + }; +} diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte index 8c00c85f47a7..a699fda42b3d 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte @@ -38,11 +38,12 @@ dimensionManager, yamlConfigProvider, openOnMount = true, - timeStart, - timeEnd, + timeStart = undefined, + timeEnd = undefined, timeDimension = undefined, timeControlsReady, smallChip = false, + removable = true, side = "bottom", isUrlTooLongAfterInListFilter = () => false, }: { @@ -50,11 +51,12 @@ dimensionManager: DimensionFilterManager; yamlConfigProvider: YAMLConfigProvider; openOnMount?: boolean; - timeStart: string | undefined; - timeEnd: string | undefined; + timeStart?: string | undefined; + timeEnd?: string | undefined; timeDimension?: string | undefined; timeControlsReady: boolean | undefined; smallChip?: boolean; + removable?: boolean; side?: "top" | "right" | "bottom" | "left"; isUrlTooLongAfterInListFilter?: (values: string[]) => boolean; } = $props(); @@ -363,7 +365,7 @@ label={`${dimensionManager.name} filter`} theme onRemove={() => dimensionManager.clear()} - removable={!pinned && !required} + removable={removable && !pinned && !required} removeTooltipText="remove {dimensionManager.selectedValues .length} value{dimensionManager.selectedValues.length !== 1 ? 's' diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte index 3f4aa7b64e43..2f6e84d776cd 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte @@ -17,12 +17,14 @@ yamlConfigProvider, timeStart, timeEnd, + showPinned, }: { manager: ExpressionFilterManager; dimensionManager: DimensionFilterManager; yamlConfigProvider: YAMLConfigProvider; timeStart: string | undefined; timeEnd: string | undefined; + showPinned: boolean; } = $props(); let effectiveLabel = $derived( @@ -32,10 +34,14 @@ ); let pinned = $derived( - Boolean(yamlConfigProvider.pinnedFilters[dimensionManager.name]), + Boolean( + showPinned && yamlConfigProvider.pinnedFilters[dimensionManager.name], + ), ); let required = $derived( - Boolean(yamlConfigProvider.requiredFilters[dimensionManager.name]), + Boolean( + showPinned && yamlConfigProvider.requiredFilters[dimensionManager.name], + ), ); let missingRequired = $derived(Boolean(required && !dimensionManager.expr)); diff --git a/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte index a309c21349ad..5f8d8a037de4 100644 --- a/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte @@ -18,12 +18,14 @@ yamlConfigProvider, openOnMount = true, allDimensions, + removable = true, side = "bottom", }: { measureManager: MeasureFilterManager; yamlConfigProvider: YAMLConfigProvider; openOnMount?: boolean; allDimensions: MetricsViewSpecDimension[]; + removable?: boolean; side?: "top" | "right" | "bottom" | "left"; } = $props(); @@ -100,7 +102,7 @@ error={!!missingRequired} theme onRemove={() => measureManager.clear()} - removable={!pinned && !required} + removable={removable && !pinned && !required} removeTooltipText={m.dashboard_remove_label({ label: measureManager.label, })} diff --git a/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte index a2917c7b9d40..ae8e562a9bd4 100644 --- a/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte @@ -8,16 +8,22 @@ let { measureManager, yamlConfigProvider, + showPinned, }: { measureManager: MeasureFilterManager; yamlConfigProvider: YAMLConfigProvider; + showPinned: boolean; } = $props(); let pinned = $derived( - Boolean(yamlConfigProvider.pinnedFilters[measureManager.name]), + Boolean( + showPinned && yamlConfigProvider.pinnedFilters[measureManager.name], + ), ); let required = $derived( - Boolean(yamlConfigProvider.requiredFilters[measureManager.name]), + Boolean( + showPinned && yamlConfigProvider.requiredFilters[measureManager.name], + ), ); let missingRequired = $derived(required && !measureManager.expr); 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/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts index 6481933dc22e..fb1c88e02ddd 100644 --- a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -39,7 +39,8 @@ export class CanvasConfigProvider extends YAMLConfigProvider { canvas: canvasName, }); this.cleanup = resolveCanvasQuery.subscribe((resolveCanvasResp) => { - const canvasSpec = resolveCanvasResp.data?.canvas ?? {}; + const canvasSpec = + resolveCanvasResp.data?.canvas?.canvas?.state?.validSpec ?? {}; this.defaultFilters = Object.fromEntries( Object.entries(canvasSpec.defaultPreset?.filterExpr ?? {}).map( 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/test/RuntimeContextHarness.svelte b/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte new file mode 100644 index 000000000000..f3edb20cebe0 --- /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/lib/arrayUtils.ts b/web-common/src/lib/arrayUtils.ts index 6d18c3df4f7c..959bbf3b69ed 100644 --- a/web-common/src/lib/arrayUtils.ts +++ b/web-common/src/lib/arrayUtils.ts @@ -27,11 +27,15 @@ export function createBatches(array: T[], batchSize: number): T[][] { return batches; } -export function arrayUnorderedEquals(src: T[], tar: T[]) { +export function arrayUnorderedEquals(src: Iterable, tar: Iterable) { const srcSet = new Set(src); const tarSet = new Set(tar); if (srcSet.size !== tarSet.size) return false; - return tar.every((t) => srcSet.has(t)); + + for (const t of tar) { + if (!srcSet.has(t)) return false; + } + return true; } export function arrayOrderedEquals(src: T[], tar: T[]) { From f12d1dd29754a5ddeca1c4820c5e3182989f0827 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 5 Aug 2026 11:25:04 +0530 Subject: [PATCH 12/21] Improve expression walking and add tests --- .../filters/ExpressionFilterManager.spec.ts | 930 ++++++++++++++++++ .../filters/ExpressionFilterManager.svelte.ts | 206 ++-- .../MetricsViewFilterManager.svelte.ts | 199 ++++ .../DimensionFilter.svelte | 4 +- .../DimensionFilterManager.svelte.ts | 11 +- .../MeasureFilterManager.svelte.ts | 13 +- .../loaders/DashboardStateSync.ts | 1 - .../providers/MetricsViewsProvider.svelte.ts | 20 +- 8 files changed, 1287 insertions(+), 97 deletions(-) create mode 100644 web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts create mode 100644 web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts new file mode 100644 index 000000000000..6fae363eaa05 --- /dev/null +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts @@ -0,0 +1,930 @@ +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; +import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts"; +import { MeasureFilterOperation } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options.ts"; +import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; +import { + createAndExpression, + createBinaryExpression, + createInExpression, + createSubQueryExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { + AD_BIDS_BID_PRICE_MEASURE, + AD_BIDS_COUNTRY_DIMENSION, + AD_BIDS_DOMAIN_DIMENSION, + AD_BIDS_IMPRESSIONS_MEASURE, + AD_BIDS_METRICS_INIT, + AD_BIDS_METRICS_NAME, + AD_BIDS_PUBLISHER_COUNT_MEASURE, + AD_BIDS_PUBLISHER_DIMENSION, +} from "@rilldata/web-common/features/dashboards/stores/test-data/data"; +import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { + createInEffectRoot, + createTestMetricsViewsProvider, + renderInRuntimeContext, + useMetricsViewMocks, + waitForMetricsViewSpecs, +} from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts"; +import { V1Operation } from "@rilldata/web-common/runtime-client"; +import { flushSync } from "svelte"; +import { get } from "svelte/store"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +// --------------------------------------------------------------------------- +// Test metrics views +// +// AdBids and its mirror share `publisher` and `impressions`. Everything else is +// defined by exactly one of them, which is what makes the per-metrics-view +// behaviour observable: +// +// publisher both domain AdBids only +// impressions both bid_price AdBids only +// country mirror only +// publisher_count mirror only +// --------------------------------------------------------------------------- + +const AD_BIDS_MIRROR_METRICS_NAME = "AdBids_mirror_metrics"; + +useMetricsViewMocks({ + [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT, + [AD_BIDS_MIRROR_METRICS_NAME]: { + ...AD_BIDS_METRICS_INIT, + dimensions: [ + { name: AD_BIDS_PUBLISHER_DIMENSION }, + { name: AD_BIDS_COUNTRY_DIMENSION }, + ], + measures: [ + { name: AD_BIDS_IMPRESSIONS_MEASURE, expression: "count(*)" }, + { + name: AD_BIDS_PUBLISHER_COUNT_MEASURE, + expression: "count_distinct(publisher)", + }, + ], + }, +}); + +// The specs are read-only, so a single provider serves every test in this file. +// Each test still gets its own ExpressionFilterManager. +let metricsViewsProvider: MetricsViewsProvider; +let destroyProvider: () => void; +const cleanups: (() => void)[] = []; + +beforeAll(async () => { + const provider = await createTestMetricsViewsProvider([ + AD_BIDS_METRICS_NAME, + AD_BIDS_MIRROR_METRICS_NAME, + ]); + metricsViewsProvider = provider.value; + destroyProvider = provider.destroy; +}); + +afterEach(() => { + cleanups.splice(0).forEach((destroy) => destroy()); +}); + +afterAll(() => destroyProvider()); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A real filter manager over the two metrics views above, torn down after the test. */ +function createFilterManager( + yamlConfigProvider: YAMLConfigProvider = new YAMLConfigProvider(), +) { + const { value, destroy } = createInEffectRoot( + () => new ExpressionFilterManager(metricsViewsProvider, yamlConfigProvider), + ); + cleanups.push(destroy); + return value; +} + +/** The `f.` params, one filter string per metrics view. */ +function perMetricsViewParams(filterByMetricsView: Record) { + const searchParams = new URLSearchParams(); + for (const [metricsViewName, filter] of Object.entries(filterByMetricsView)) { + searchParams.set( + `${ExploreStateURLParams.Filters}.${metricsViewName}`, + filter, + ); + } + return searchParams; +} + +/** The singular `f` param, which every metrics view falls back to. */ +function sharedParam(filter: string) { + return new URLSearchParams({ [ExploreStateURLParams.Filters]: filter }); +} + +function names(managers: { name: string }[]) { + return managers.map((manager) => manager.name); +} + +function dimensionManagersOf( + filterManager: ExpressionFilterManager, + metricsViewName: string, +) { + return filterManager.managerByMetricsView[metricsViewName].managers + .dimensionManagers; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("setUrlParams", () => { + it("builds a dimension chip from a per metrics view param", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + }), + ); + + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + ]); + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + ]); + expect(filterManager.filterManagers.dimensions[0].mode).toBe( + DimensionFilterMode.Select, + ); + expect(filterManager.filterManagers.measures).toEqual([]); + expect(filterManager.isComplexFilter).toBe(false); + }); + + it("builds a measure chip from a having param", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10)`, + }), + ); + + expect(names(filterManager.filterManagers.measures)).toEqual([ + AD_BIDS_IMPRESSIONS_MEASURE, + ]); + const measureManager = filterManager.filterManagers.measures[0]; + expect(measureManager.dimension).toBe(AD_BIDS_PUBLISHER_DIMENSION); + expect(measureManager.operation).toBe(MeasureFilterOperation.GreaterThan); + expect(measureManager.value1).toBe("10"); + expect(filterManager.filterManagers.dimensions).toEqual([]); + }); + + it("reads an in-list filter back as in-list mode", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN LIST ('Google','Facebook')`, + }), + ); + + expect(filterManager.filterManagers.dimensions[0].mode).toBe( + DimensionFilterMode.InList, + ); + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + "Facebook", + ]); + }); + + it("applies the singular param to every metrics view and shares one manager", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), + ); + + expect(Object.keys(filterManager.managerByMetricsView)).toEqual([ + AD_BIDS_METRICS_NAME, + AD_BIDS_MIRROR_METRICS_NAME, + ]); + // One chip, backed by a single manager instance, so editing it updates the + // filter for both metrics views at once. + expect(filterManager.filterManagers.dimensions).toHaveLength(1); + expect(dimensionManagersOf(filterManager, AD_BIDS_METRICS_NAME)[0]).toBe( + dimensionManagersOf(filterManager, AD_BIDS_MIRROR_METRICS_NAME)[0], + ); + }); + + it("prefers the per metrics view param over the singular one", () => { + const filterManager = createFilterManager(); + + const searchParams = sharedParam( + `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + ); + searchParams.set( + `${ExploreStateURLParams.Filters}.${AD_BIDS_MIRROR_METRICS_NAME}`, + `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + ); + filterManager.setUrlParams(searchParams); + + expect( + names(dimensionManagersOf(filterManager, AD_BIDS_METRICS_NAME)), + ).toEqual([AD_BIDS_PUBLISHER_DIMENSION]); + expect( + names(dimensionManagersOf(filterManager, AD_BIDS_MIRROR_METRICS_NAME)), + ).toEqual([AD_BIDS_COUNTRY_DIMENSION]); + // Chips are the union across metrics views, sorted by name. + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_COUNTRY_DIMENSION, + AD_BIDS_PUBLISHER_DIMENSION, + ]); + }); + + it("drops filters on identifiers the metrics view does not define", () => { + const filterManager = createFilterManager(); + + // `country` belongs to the mirror only, so AdBids cannot filter on it. + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + }), + ); + + expect(dimensionManagersOf(filterManager, AD_BIDS_METRICS_NAME)).toEqual( + [], + ); + expect(filterManager.filterManagers.dimensions).toEqual([]); + }); + + it("keeps the managers when the params do not change", () => { + const filterManager = createFilterManager(); + const filter = `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`; + + filterManager.setUrlParams( + perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: filter }), + ); + const dimensionManager = filterManager.filterManagers.dimensions[0]; + + filterManager.setUrlParams( + perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: filter }), + ); + + expect(filterManager.filterManagers.dimensions[0]).toBe(dimensionManager); + }); + + it("flags an OR filter as complex", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') OR ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + }), + ); + + expect(filterManager.isComplexFilter).toBe(true); + expect( + filterManager.managerByMetricsView[AD_BIDS_METRICS_NAME].isComplexFilter, + ).toBe(true); + }); + + it("flags two filters on the same dimension as complex", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_PUBLISHER_DIMENSION} NIN ('Facebook')`, + }), + ); + + expect( + filterManager.managerByMetricsView[AD_BIDS_METRICS_NAME].isComplexFilter, + ).toBe(true); + }); + + it("clears the temporary filter name", () => { + const filterManager = createFilterManager(); + + filterManager.addNewFilter(AD_BIDS_DOMAIN_DIMENSION); + expect(filterManager.temporaryFilterName).toBe(AD_BIDS_DOMAIN_DIMENSION); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + }), + ); + + expect(filterManager.temporaryFilterName).toBeUndefined(); + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + ]); + }); +}); + +describe("setExprForMetricsView / setParamForMetricsView", () => { + it("sets the filter for one metrics view without touching the others", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), + ); + filterManager.setParamForMetricsView( + AD_BIDS_MIRROR_METRICS_NAME, + `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + ); + + expect( + names(dimensionManagersOf(filterManager, AD_BIDS_METRICS_NAME)), + ).toEqual([AD_BIDS_PUBLISHER_DIMENSION]); + expect( + names(dimensionManagersOf(filterManager, AD_BIDS_MIRROR_METRICS_NAME)), + ).toEqual([AD_BIDS_COUNTRY_DIMENSION]); + }); + + it("converts an expression to a param", () => { + const filterManager = createFilterManager(); + + filterManager.setExprForMetricsView( + AD_BIDS_METRICS_NAME, + createAndExpression([ + createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Google", "Facebook"]), + ]), + ); + + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + "Facebook", + ]); + }); + + it("clears the filter for a metrics view when given no expression", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), + ); + filterManager.setExprForMetricsView(AD_BIDS_METRICS_NAME, undefined); + + expect( + filterManager.managerByMetricsView[AD_BIDS_METRICS_NAME], + ).toBeUndefined(); + expect( + names(dimensionManagersOf(filterManager, AD_BIDS_MIRROR_METRICS_NAME)), + ).toEqual([AD_BIDS_PUBLISHER_DIMENSION]); + }); +}); + +describe("chip order", () => { + it("leads with required filters, then pinned, then the param filters by name", () => { + const yamlConfigProvider = new YAMLConfigProvider(); + yamlConfigProvider.requiredFilters = { [AD_BIDS_DOMAIN_DIMENSION]: true }; + yamlConfigProvider.pinnedFilters = { [AD_BIDS_PUBLISHER_DIMENSION]: true }; + const filterManager = createFilterManager(yamlConfigProvider); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + }), + ); + + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_DOMAIN_DIMENSION, + AD_BIDS_PUBLISHER_DIMENSION, + AD_BIDS_COUNTRY_DIMENSION, + ]); + }); + + it("gives required and pinned filters a chip without a value", () => { + const yamlConfigProvider = new YAMLConfigProvider(); + yamlConfigProvider.requiredFilters = { [AD_BIDS_DOMAIN_DIMENSION]: true }; + yamlConfigProvider.pinnedFilters = { [AD_BIDS_IMPRESSIONS_MEASURE]: true }; + const filterManager = createFilterManager(yamlConfigProvider); + + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_DOMAIN_DIMENSION, + ]); + expect(filterManager.filterManagers.dimensions[0].expr).toBeUndefined(); + expect(names(filterManager.filterManagers.measures)).toEqual([ + AD_BIDS_IMPRESSIONS_MEASURE, + ]); + expect(filterManager.filterManagers.measures[0].expr).toBeUndefined(); + }); + + it("skips required filters that no metrics view defines", () => { + const yamlConfigProvider = new YAMLConfigProvider(); + yamlConfigProvider.requiredFilters = { not_a_field: true }; + const filterManager = createFilterManager(yamlConfigProvider); + + expect(filterManager.filterManagers.dimensions).toEqual([]); + expect(filterManager.filterManagers.measures).toEqual([]); + }); + + it("sorts the param measures by name", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10) AND ${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE} lt 2)`, + }), + ); + + expect(names(filterManager.filterManagers.measures)).toEqual([ + AD_BIDS_BID_PRICE_MEASURE, + AD_BIDS_IMPRESSIONS_MEASURE, + ]); + }); + + it("keys filterManagersMap by chip name", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE} lt 2)`, + }), + ); + + expect(Object.keys(filterManager.filterManagersMap).sort()).toEqual([ + AD_BIDS_BID_PRICE_MEASURE, + AD_BIDS_PUBLISHER_DIMENSION, + ]); + expect( + filterManager.filterManagersMap[AD_BIDS_PUBLISHER_DIMENSION], + ).toBeInstanceOf(DimensionFilterManager); + expect( + filterManager.filterManagersMap[AD_BIDS_BID_PRICE_MEASURE], + ).toBeInstanceOf(MeasureFilterManager); + }); +}); + +describe("addNewFilter", () => { + it("opens a chip for a dimension that has no value yet", () => { + const filterManager = createFilterManager(); + + filterManager.addNewFilter(AD_BIDS_DOMAIN_DIMENSION); + + expect(filterManager.temporaryFilterName).toBe(AD_BIDS_DOMAIN_DIMENSION); + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_DOMAIN_DIMENSION, + ]); + expect(filterManager.filterManagers.dimensions[0].expr).toBeUndefined(); + }); + + it("opens a chip for a measure that has no value yet", () => { + const filterManager = createFilterManager(); + + filterManager.addNewFilter(AD_BIDS_BID_PRICE_MEASURE); + + expect(names(filterManager.filterManagers.measures)).toEqual([ + AD_BIDS_BID_PRICE_MEASURE, + ]); + expect(filterManager.filterManagers.dimensions).toEqual([]); + }); + + it("ignores a name no metrics view defines", () => { + const filterManager = createFilterManager(); + + filterManager.addNewFilter("not_a_field"); + + expect(filterManager.temporaryFilterName).toBeUndefined(); + expect(filterManager.filterManagers.dimensions).toEqual([]); + expect(filterManager.filterManagers.measures).toEqual([]); + }); + + it("does not duplicate a chip the param already covers", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + }), + ); + filterManager.addNewFilter(AD_BIDS_PUBLISHER_DIMENSION); + + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + ]); + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + ]); + }); +}); + +describe("dimensionFilterAction", () => { + it("creates and keeps a manager for a dimension the param does not cover", () => { + const filterManager = createFilterManager(); + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.setSelectedValues(["Google"], false), + ); + + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + ]); + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + ]); + expect( + filterManager.filterManagersMap[AD_BIDS_PUBLISHER_DIMENSION], + ).toBeInstanceOf(DimensionFilterManager); + }); + + it("drops the new manager when the callback leaves it without an expression", () => { + const filterManager = createFilterManager(); + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.clear(), + ); + + expect(filterManager.filterManagers.dimensions).toEqual([]); + }); + + it("reuses the manager the param created", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + }), + ); + const existing = filterManager.filterManagers.dimensions[0]; + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => { + expect(manager).toBe(existing); + manager.toggleValue("Facebook", false); + }, + ); + + expect(existing.selectedValues).toEqual(["Google", "Facebook"]); + expect(filterManager.filterManagers.dimensions).toHaveLength(1); + }); + + it("returns the callback result", () => { + const filterManager = createFilterManager(); + + // The callback return type is `any`, hence the cast. + const result = filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.selectedValues.length, + ) as number; + + expect(result).toBe(0); + }); + + it("does nothing for a measure or an unknown name", () => { + const filterManager = createFilterManager(); + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10)`, + }), + ); + const callback = vi.fn(); + + expect( + filterManager.dimensionFilterAction( + AD_BIDS_IMPRESSIONS_MEASURE, + callback, + ), + ).toBeUndefined(); + expect( + filterManager.dimensionFilterAction("not_a_field", callback), + ).toBeUndefined(); + expect(callback).not.toHaveBeenCalled(); + }); +}); + +describe("clear", () => { + it("removes every chip and the temporary filter", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + }), + ); + filterManager.addNewFilter(AD_BIDS_DOMAIN_DIMENSION); + + filterManager.clear(); + + expect(filterManager.temporaryFilterName).toBeUndefined(); + expect(filterManager.managerByMetricsView).toEqual({}); + expect(filterManager.filterManagers.dimensions).toEqual([]); + expect(filterManager.filterManagers.measures).toEqual([]); + }); + + it("keeps the required and pinned chips", () => { + const yamlConfigProvider = new YAMLConfigProvider(); + yamlConfigProvider.requiredFilters = { [AD_BIDS_DOMAIN_DIMENSION]: true }; + const filterManager = createFilterManager(yamlConfigProvider); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + }), + ); + filterManager.clear(); + + expect(names(filterManager.filterManagers.dimensions)).toEqual([ + AD_BIDS_DOMAIN_DIMENSION, + ]); + expect(filterManager.filterManagers.dimensions[0].expr).toBeUndefined(); + }); +}); + +describe("getOtherDimensionsFilter", () => { + function setupThreeDimensions() { + const filterManager = createFilterManager(); + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + }), + ); + return filterManager; + } + + it("ands the other dimension filters that apply to the metrics view", () => { + const filterManager = setupThreeDimensions(); + + // `country` is a mirror-only dimension, so it is left out of the AdBids filter. + expect( + filterManager.getOtherDimensionsFilter( + AD_BIDS_PUBLISHER_DIMENSION, + AD_BIDS_METRICS_NAME, + ), + ).toEqual( + createAndExpression([ + createInExpression(AD_BIDS_DOMAIN_DIMENSION, ["google.com"]), + ]), + ); + }); + + it("only includes dimensions the given metrics view defines", () => { + const filterManager = setupThreeDimensions(); + + expect( + filterManager.getOtherDimensionsFilter( + AD_BIDS_PUBLISHER_DIMENSION, + AD_BIDS_MIRROR_METRICS_NAME, + ), + ).toEqual( + createAndExpression([ + createInExpression(AD_BIDS_COUNTRY_DIMENSION, ["US"]), + ]), + ); + }); + + it("returns undefined when no other dimension is filtered", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, + }), + ); + + expect( + filterManager.getOtherDimensionsFilter( + AD_BIDS_PUBLISHER_DIMENSION, + AD_BIDS_METRICS_NAME, + ), + ).toBeUndefined(); + }); +}); + +describe("manager factories", () => { + it("creates a dimension manager for a dimension name", () => { + const filterManager = createFilterManager(); + + const manager = filterManager.createDimensionOrMeasureManagerForName( + AD_BIDS_PUBLISHER_DIMENSION, + ); + + expect(manager).toBeInstanceOf(DimensionFilterManager); + expect(manager?.name).toBe(AD_BIDS_PUBLISHER_DIMENSION); + expect(manager?.label).toBe(AD_BIDS_PUBLISHER_DIMENSION); + expect(manager?.expr).toBeUndefined(); + }); + + it("creates a measure manager for a measure name", () => { + const filterManager = createFilterManager(); + + const manager = filterManager.createDimensionOrMeasureManagerForName( + AD_BIDS_BID_PRICE_MEASURE, + ); + + expect(manager).toBeInstanceOf(MeasureFilterManager); + expect(manager?.name).toBe(AD_BIDS_BID_PRICE_MEASURE); + }); + + it("returns undefined for a name no metrics view defines", () => { + const filterManager = createFilterManager(); + + expect( + filterManager.createDimensionOrMeasureManagerForName("not_a_field"), + ).toBeUndefined(); + }); + + it("creates a dimension manager from an in expression", () => { + const filterManager = createFilterManager(); + + const manager = filterManager.createDimensionOrMeasureManagerForExpr( + createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Google"]), + ); + + expect(manager).toBeInstanceOf(DimensionFilterManager); + expect((manager as DimensionFilterManager).selectedValues).toEqual([ + "Google", + ]); + expect((manager as DimensionFilterManager).mode).toBe( + DimensionFilterMode.Select, + ); + }); + + it("honours the in-list dimensions when creating from an expression", () => { + const filterManager = createFilterManager(); + + const manager = filterManager.createDimensionOrMeasureManagerForExpr( + createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Google"]), + [AD_BIDS_PUBLISHER_DIMENSION], + ); + + expect((manager as DimensionFilterManager).mode).toBe( + DimensionFilterMode.InList, + ); + }); + + it("creates a measure manager from a subquery expression", () => { + const filterManager = createFilterManager(); + + const manager = filterManager.createDimensionOrMeasureManagerForExpr( + createSubQueryExpression( + AD_BIDS_PUBLISHER_DIMENSION, + [AD_BIDS_IMPRESSIONS_MEASURE], + createBinaryExpression( + AD_BIDS_IMPRESSIONS_MEASURE, + V1Operation.OPERATION_GT, + 10, + ), + ), + ); + + expect(manager).toBeInstanceOf(MeasureFilterManager); + expect((manager as MeasureFilterManager).dimension).toBe( + AD_BIDS_PUBLISHER_DIMENSION, + ); + expect((manager as MeasureFilterManager).value1).toBe("10"); + }); + + it("returns undefined when the expression is keyed off an unknown dimension", () => { + const filterManager = createFilterManager(); + + expect( + filterManager.createDimensionOrMeasureManagerForExpr( + createInExpression("not_a_dimension", ["Google"]), + ), + ).toBeUndefined(); + }); +}); + +describe("createLocalFilterStore", () => { + it("scopes a new manager to a single metrics view and shares the yaml config", async () => { + const yamlConfigProvider = new YAMLConfigProvider(); + // The local store builds its own MetricsViewsProvider, which calls createQuery, + // so it has to be created inside a component rather than a bare effect root. + const rendered = renderInRuntimeContext(({ runtimeClient }) => { + const provider = new MetricsViewsProvider(runtimeClient, [ + AD_BIDS_METRICS_NAME, + AD_BIDS_MIRROR_METRICS_NAME, + ]); + const filterManager = new ExpressionFilterManager( + provider, + yamlConfigProvider, + ); + return { + provider, + filterManager, + local: filterManager.createLocalFilterStore(AD_BIDS_METRICS_NAME), + }; + }); + cleanups.push(() => { + rendered.value.local.metricsViewsProvider.cleanup(); + rendered.value.provider.cleanup(); + rendered.destroy(); + }); + + const { local } = rendered.value; + await waitForMetricsViewSpecs(local.metricsViewsProvider, [ + AD_BIDS_METRICS_NAME, + ]); + + expect(local.metricsViewsProvider.metricsViewNames).toEqual([ + AD_BIDS_METRICS_NAME, + ]); + expect(local.yamlConfigProvider).toBe(yamlConfigProvider); + + // The mirror-only dimension is out of scope for the local store. + local.setUrlParams(sharedParam(`${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`)); + expect(local.filterManagers.dimensions).toEqual([]); + + local.setUrlParams( + sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), + ); + expect(names(local.filterManagers.dimensions)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + ]); + // The parent manager is untouched. + expect(rendered.value.filterManager.filterManagers.dimensions).toEqual([]); + }); +}); + +describe("exprByMetricsView", () => { + it("builds an expression per metrics view", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, + }), + ); + + expect(filterManager.exprByMetricsView).toEqual({ + [AD_BIDS_METRICS_NAME]: createAndExpression([ + createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Google"]), + createInExpression(AD_BIDS_DOMAIN_DIMENSION, ["google.com"]), + ]), + [AD_BIDS_MIRROR_METRICS_NAME]: createAndExpression([ + createInExpression(AD_BIDS_COUNTRY_DIMENSION, ["US"]), + ]), + }); + expect(filterManager.hasSomeFilter).toBe(true); + }); + + it("omits metrics views with no filter", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + }), + ); + + expect(Object.keys(filterManager.exprByMetricsView)).toEqual([ + AD_BIDS_METRICS_NAME, + ]); + }); + + it("is empty before any filter is set", () => { + const filterManager = createFilterManager(); + + expect(filterManager.exprByMetricsView).toEqual({}); + expect(filterManager.hasSomeFilter).toBe(false); + }); + + it("picks up a filter added outside the params", () => { + const filterManager = createFilterManager(); + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.setSelectedValues(["Google"], false), + ); + + // `publisher` belongs to both metrics views, so both get the filter. + expect(filterManager.exprByMetricsView).toEqual({ + [AD_BIDS_METRICS_NAME]: createAndExpression([ + createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Google"]), + ]), + [AD_BIDS_MIRROR_METRICS_NAME]: createAndExpression([ + createInExpression(AD_BIDS_PUBLISHER_DIMENSION, ["Google"]), + ]), + }); + }); + + it("mirrors exprByMetricsView into the svelte 4 store", () => { + const filterManager = createFilterManager(); + + filterManager.setUrlParams( + perMetricsViewParams({ + [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, + }), + ); + flushSync(); + + expect(get(filterManager.exprByMetricsViewStore)).toEqual( + filterManager.exprByMetricsView, + ); + }); +}); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 1e62f6b16708..35704655e32c 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -1,12 +1,14 @@ import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts"; -import { type V1Expression } from "@rilldata/web-common/runtime-client"; +import { + type V1Expression, + V1Operation, +} from "@rilldata/web-common/runtime-client"; import { convertExpressionToFilterParam, convertFilterParamToExpression, } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; import { createAndExpression, - forEachIdentifier, isExpressionUnsupported, maybeWrapAndExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; @@ -15,14 +17,21 @@ import { getMeasureDisplayName, } from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts"; -import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { + type MetricsViewName, + 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; import { writable, type Writable } from "svelte/store"; +import { + type AnyManager, + type DimensionOrMeasureManager, + MetricsViewFilterManager, +} from "@rilldata/web-common/features/dashboards/filters/MetricsViewFilterManager.svelte.ts"; -type FilterManager = MeasureFilterManager | DimensionFilterManager; type ParsedUrlParamsByMV = Record< - string, + MetricsViewName, ReturnType >; @@ -35,11 +44,6 @@ type ParsedUrlParamsByMV = Record< * from the filter menu, and the ones created by a chart interaction. */ export class ExpressionFilterManager { - // Filter param the managers are derived from. - public exprParam: string = $state(""); - // Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. - public temporaryFilterName: string | undefined = $state(undefined); - // True when the param cannot be represented as chips. public isComplexFilter: boolean; public exprByMetricsView: Record; @@ -48,11 +52,17 @@ export class ExpressionFilterManager { writable({}); public hasSomeFilter: boolean; + // Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. + public temporaryFilterName: string | undefined = $state(undefined); + public managerByMetricsView: Record; public filterManagers: { measures: MeasureFilterManager[]; dimensions: DimensionFilterManager[]; }; - public readonly filterManagersMap: Record; + public readonly filterManagersMap: Record< + string, + DimensionOrMeasureManager[] + >; // Raw parsed expression from url params. Validated expression is stored in exprByMetricsView. private prevUrlParams = $state(""); @@ -62,6 +72,27 @@ export class ExpressionFilterManager { public readonly metricsViewsProvider: MetricsViewsProvider, public readonly yamlConfigProvider: YAMLConfigProvider, ) { + this.managerByMetricsView = $derived.by(() => { + const existingManagers = new Map(); + return Object.fromEntries( + Object.entries(this.parsedUrlParams) + .map(([mvName, { expr, dimensionsWithInlistFilter }]) => [ + mvName, + MetricsViewFilterManager.parse( + this.metricsViewsProvider, + mvName, + maybeWrapAndExpression(expr), + dimensionsWithInlistFilter, + existingManagers, + ), + ]) + .filter(([, manager]) => !!manager) as [ + string, + MetricsViewFilterManager, + ][], + ); + }); + this.filterManagers = $derived.by(() => this.buildFilterManagers(this.buildParamFilterManagers()), ); @@ -101,9 +132,12 @@ export class ExpressionFilterManager { const { expr, dimensionsWithInlistFilter } = convertFilterParamToExpression(filterString); - isSomeComplexFilter ||= expr ? isExpressionUnsupported(expr) : false; + const wrappedExpr = maybeWrapAndExpression(expr); + isSomeComplexFilter ||= expr + ? isExpressionUnsupported(wrappedExpr!) + : false; newParsedUrlParams[name] = { - expr: maybeWrapAndExpression(expr), + expr: wrappedExpr, dimensionsWithInlistFilter, }; }); @@ -113,6 +147,7 @@ export class ExpressionFilterManager { this.prevUrlParams = newParam; this.parsedUrlParams = newParsedUrlParams; this.isComplexFilter = isSomeComplexFilter; + this.temporaryFilterName = undefined; }; public setExprForMetricsView(mvName: string, expr: V1Expression | undefined) { @@ -232,30 +267,22 @@ export class ExpressionFilterManager { /** Managers for the filters in the param, keyed by name and in param order. */ private buildParamFilterManagers() { - const measuresMap = new Map(); - const dimensionsMap = new Map(); - - if (this.isComplexFilter) return { measuresMap, dimensionsMap }; + const dimensionsMap = {} as Record; + const measuresMap = {} as Record; // Go through exprs of parsed url params for each metrics views. - Object.entries(this.parsedUrlParams).forEach( - ([, { expr, dimensionsWithInlistFilter }]) => { - if (!expr) return; - - forEachIdentifier(expr, (e, ident) => { - const manager = this.createDimensionOrMeasureManagerForExpr( - e, - dimensionsWithInlistFilter, - ); - if (!manager) return; - if (manager instanceof DimensionFilterManager) { - dimensionsMap.set(ident, manager); - } else if (manager instanceof MeasureFilterManager) { - measuresMap.set(ident, manager); - } - }); - }, - ); + Object.entries(this.managerByMetricsView).forEach(([, mvManager]) => { + if (!mvManager.expr) return; + + mvManager.managers.dimensionManagers.forEach((manager) => { + dimensionsMap[manager.name] ??= []; + dimensionsMap[manager.name].push(manager); + }); + mvManager.managers.measureManagers.forEach((manager) => { + measuresMap[manager.name] ??= []; + measuresMap[manager.name].push(manager); + }); + }); return { measuresMap, dimensionsMap }; } @@ -265,11 +292,11 @@ export class ExpressionFilterManager { * filters in param order, and finally the filters the param does not cover yet. */ private buildFilterManagers({ - measuresMap, dimensionsMap, + measuresMap, }: { - measuresMap: Map; - dimensionsMap: Map; + dimensionsMap: Record; + measuresMap: Record; }) { const dimensions: DimensionFilterManager[] = []; const measures: MeasureFilterManager[] = []; @@ -279,8 +306,8 @@ export class ExpressionFilterManager { if (added.has(name)) return; const filterManager = - dimensionsMap.get(name) ?? - measuresMap.get(name) ?? + dimensionsMap[name]?.[0] ?? + measuresMap[name]?.[0] ?? this.createDimensionOrMeasureManagerForName(name); if (!filterManager) return; @@ -300,27 +327,78 @@ export class ExpressionFilterManager { // Next comes dimensions from params const shouldAddTempDimension = Boolean( this.temporaryFilterName && - !dimensionsMap.has(this.temporaryFilterName) && + !(this.temporaryFilterName in dimensionsMap) && this.temporaryFilterName in this.metricsViewsProvider.dimensionSpecs, ); sortFilterIterator( - dimensionsMap.keys(), + Object.keys(dimensionsMap), shouldAddTempDimension ? this.temporaryFilterName : undefined, ).forEach((name) => add(name)); // Next comes measures from params const shouldAddTempMeasure = this.temporaryFilterName && - !measuresMap.has(this.temporaryFilterName) && + !(this.temporaryFilterName in measuresMap) && this.temporaryFilterName in this.metricsViewsProvider.measureSpecs; sortFilterIterator( - measuresMap.keys(), + Object.keys(measuresMap), shouldAddTempMeasure ? this.temporaryFilterName : undefined, ).forEach((name) => add(name)); return { measures, dimensions }; } + /** + * The filter to send with queries for each metrics view. + * + * The param contributes a whole expression per metrics view, so any nesting it has survives. + * Chips the param does not cover yet, the ones from the filter menu or a chart interaction, + * are added to every metrics view that defines them. + */ + private buildExpressionsByMetricsView() { + const exprsByMetricsView = {} as Record; + const addExpr = (metricsViewName: string, expr: V1Expression) => { + exprsByMetricsView[metricsViewName] ??= []; + // Flattening a top level AND keeps the result a single flat AND in the common case. + if (expr.cond?.op === V1Operation.OPERATION_AND) { + exprsByMetricsView[metricsViewName].push(...(expr.cond.exprs ?? [])); + } else { + exprsByMetricsView[metricsViewName].push(expr); + } + }; + + const managersInParam = new Set(); + Object.entries(this.managerByMetricsView).forEach( + ([metricsViewName, mvManager]) => { + Object.values(mvManager.managerLookup) + .flat() + .forEach((manager) => managersInParam.add(manager)); + if (mvManager.expr) addExpr(metricsViewName, mvManager.expr); + }, + ); + + [...this.filterManagers.dimensions, ...this.filterManagers.measures] + .filter((manager) => !!manager.expr && !managersInParam.has(manager)) + .forEach((manager) => { + const specs = + this.metricsViewsProvider.dimensionSpecs[manager.name] ?? + this.metricsViewsProvider.measureSpecs[manager.name] ?? + {}; + Object.keys(specs).forEach((metricsViewName) => + addExpr(metricsViewName, manager.expr!), + ); + }); + + return Object.fromEntries( + Object.entries(exprsByMetricsView) + .filter(([, exprs]) => exprs.length > 0) + .map(([metricsViewName, exprs]) => [ + metricsViewName, + createAndExpression(exprs), + ]), + ); + } + private createMeasureFilterManager(name: string, initExpr?: V1Expression) { const measureSpecs = this.metricsViewsProvider.measureSpecs[name]; if (!measureSpecs) return undefined; @@ -347,46 +425,10 @@ export class ExpressionFilterManager { isInList, ); } - - private buildExpressionsByMetricsView() { - const exprsByMetricsView = Object.fromEntries( - this.metricsViewsProvider.metricsViewNames.map((m) => [ - m, - [] as V1Expression[], - ]), - ); - - this.filterManagers.dimensions.forEach((dfm) => { - if (!dfm.expr) return; - Object.keys( - this.metricsViewsProvider.dimensionSpecs[dfm.name] ?? {}, - ).forEach((mv) => { - exprsByMetricsView[mv]?.push(dfm.expr!); - }); - }); - - this.filterManagers.measures.forEach((mfm) => { - if (!mfm.expr) return; - Object.keys( - this.metricsViewsProvider.measureSpecs[mfm.name] ?? {}, - ).forEach((mv) => { - exprsByMetricsView[mv]?.push(mfm.expr!); - }); - }); - - return Object.fromEntries( - Object.entries(exprsByMetricsView) - .filter(([, exprs]) => exprs.length > 0) - .map(([mv, exprs]) => [mv, createAndExpression(exprs)]), - ); - } } -function sortFilterIterator( - iterator: MapIterator | SetIterator, - tempName: string | undefined, -) { - const newArr = [...iterator]; +function sortFilterIterator(arr: string[], tempName: string | undefined) { + const newArr = [...arr]; if (tempName) newArr.push(tempName); return newArr.sort((a, b) => (a > b ? 1 : -1)); } diff --git a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts new file mode 100644 index 000000000000..b6935a310b90 --- /dev/null +++ b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts @@ -0,0 +1,199 @@ +import { + type V1Expression, + V1Operation, +} from "@rilldata/web-common/runtime-client"; +import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts"; +import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts"; +import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { + getDimensionDisplayName, + getMeasureDisplayName, +} from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; +import { + createAndExpression, + createOrExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; + +export type DimensionOrMeasureManager = + | DimensionFilterManager + | MeasureFilterManager; +export type AnyManager = DimensionOrMeasureManager | MetricsViewFilterManager; + +/** + * Manager for an AND/OR expression for a metrics view. + */ +export class MetricsViewFilterManager { + public expr: V1Expression | undefined; + // String representation of the filter expression. Used to check duplicate expressions across metrics views. + public param: string; + + public managerLookup: Record< + string, + (DimensionFilterManager | MeasureFilterManager)[] + >; + public hasSomeFilter: boolean; + public isComplexFilter: boolean; + + public constructor( + public readonly metricsViewName: string, + public type: + | typeof V1Operation.OPERATION_AND + | typeof V1Operation.OPERATION_OR, + public readonly managers: { + dimensionManagers: DimensionFilterManager[]; + measureManagers: MeasureFilterManager[]; + joinerManagers: MetricsViewFilterManager[]; + }, + private readonly inList: string[], + ) { + this.expr = $derived(this.buildExpressionsByMetricsView()); + this.param = $derived( + this.expr ? convertExpressionToFilterParam(this.expr, this.inList) : "", + ); + + this.managerLookup = $derived(this.buildManagerLookup()); + this.hasSomeFilter = $derived(Object.values(this.managerLookup).length > 0); + // Currently only single level AND joiner is supported where each dimension/measure has a single filter. + // Anything else is considered complex filter, and we only show an editable text box. + this.isComplexFilter = $derived( + this.type === V1Operation.OPERATION_OR || + Object.values(this.managerLookup).some( + (managers) => managers.length > 1, + ) || + this.managers.joinerManagers.length > 0, + ); + } + + public static parse( + metricsViewsProvider: MetricsViewsProvider, + metricsViewName: string, + expr: V1Expression | undefined, + dimensionsWithInlistFilter: string[], + existingManagers: Map, + ): AnyManager | undefined { + const op = expr?.cond?.op; + if (op === V1Operation.OPERATION_AND || op === V1Operation.OPERATION_OR) { + const dimensionManagers: DimensionFilterManager[] = []; + const measureManagers: MeasureFilterManager[] = []; + const joinerManagers: MetricsViewFilterManager[] = []; + + expr?.cond?.exprs?.forEach((e) => { + let manager = MetricsViewFilterManager.parse( + metricsViewsProvider, + metricsViewName, + e, + dimensionsWithInlistFilter, + existingManagers, + ); + if (!manager) return; + + // Prefer to share class for similar expressions. + // This is a hack to make a single filter across the metrics view work. + // TODO: find a better solution to share + if (existingManagers.has(manager.param)) { + manager = existingManagers.get(manager.param)!; + } else { + existingManagers.set(manager.param, manager); + } + + if (manager instanceof DimensionFilterManager) { + dimensionManagers.push(manager); + } else if (manager instanceof MeasureFilterManager) { + measureManagers.push(manager); + } else if (manager instanceof MetricsViewFilterManager) { + joinerManagers.push(manager); + } + }); + + return new MetricsViewFilterManager( + metricsViewName, + op, + { + dimensionManagers, + measureManagers, + joinerManagers, + }, + dimensionsWithInlistFilter, + ); + } else { + const ident = expr?.cond?.exprs?.[0]?.ident ?? ""; + // Both filter types are keyed off a dimension: a dimension filter by the dimension it filters + // and a measure filter by the dimension its subquery groups by. + if (!metricsViewsProvider.dimensionSpecs[ident]?.[metricsViewName]) { + return undefined; + } + + const firstValueExpr = expr?.cond?.exprs?.[1]; + + if (firstValueExpr?.subquery) { + // Having a subquery means this is a measure filter. + const measureName = firstValueExpr.subquery.measures?.[0]; + if (!measureName) return undefined; + const measureSpec = + metricsViewsProvider.measureSpecs[measureName]?.[metricsViewName]; + if (!measureSpec) return undefined; + + return new MeasureFilterManager( + measureName, + getMeasureDisplayName(measureSpec), + firstValueExpr, + ); + } else { + // Everything else is a dimension filter for now. + const dimensionSpec = + metricsViewsProvider.dimensionSpecs[ident]?.[metricsViewName]; + if (!dimensionSpec) return undefined; + + return new DimensionFilterManager( + ident, + getDimensionDisplayName(dimensionSpec), + expr, + dimensionsWithInlistFilter.includes(ident), + ); + } + } + } + + private buildExpressionsByMetricsView() { + const dimensionExprs = this.managers.dimensionManagers + .map((dfm) => dfm.expr) + .filter(Boolean) as V1Expression[]; + const measureExprs = this.managers.measureManagers + .map((mfm) => mfm.expr) + .filter(Boolean) as V1Expression[]; + const joinerExprs = this.managers.joinerManagers + .map((jm) => jm.expr) + .filter(Boolean) as V1Expression[]; + const exprs = dimensionExprs.concat(measureExprs).concat(joinerExprs); + if (exprs.length === 0) return undefined; + + if (this.type === V1Operation.OPERATION_AND) { + return createAndExpression(exprs); + } else { + return createOrExpression(exprs); + } + } + + private buildManagerLookup() { + const lookup = {} as Record< + string, + (DimensionFilterManager | MeasureFilterManager)[] + >; + this.managers.dimensionManagers.forEach((manager) => { + lookup[manager.name] ??= []; + lookup[manager.name].push(manager); + }); + this.managers.measureManagers.forEach((manager) => { + lookup[manager.name] ??= []; + lookup[manager.name].push(manager); + }); + this.managers.joinerManagers.forEach((manager) => { + Object.entries(manager.managerLookup).forEach(([name, managers]) => { + lookup[name] ??= []; + lookup[name].push(...managers); + }); + }); + return lookup; + } +} diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte index a699fda42b3d..8a876a3ceb67 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte @@ -504,7 +504,7 @@ class="text-xs cursor-pointer" checked={selected} showXForSelected={proxyDimensionManager.exclude} - onclick={() => handleItemClick(name)} + onCheckedChange={() => handleItemClick(name)} > {#if label.length > 240} @@ -542,7 +542,7 @@ showXForSelected={proxyDimensionManager.exclude} disabled={proxyDimensionManager.mode !== DimensionFilterMode.Select} - onclick={() => handleItemClick(name)} + onCheckedChange={() => handleItemClick(name)} > {#if label.length > 240} diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts index 3d04ad0ea7fb..f7752458ca4c 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts @@ -11,9 +11,12 @@ import { 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"; 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([]); @@ -147,7 +150,6 @@ export class DimensionFilterManager { public clear() { this.selectedValues = []; this.inputText = ""; - this.expr = undefined; this.commit(); } @@ -181,6 +183,13 @@ export class DimensionFilterManager { break; } this.oldMode = this.mode; + + this.param = this.expr + ? convertExpressionToFilterParam( + this.expr, + this.mode === DimensionFilterMode.InList ? [this.name] : [], + ) + : ""; } } diff --git a/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts index 96baaae06379..133de097c6c8 100644 --- a/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts @@ -12,9 +12,12 @@ 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"; 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); @@ -31,11 +34,10 @@ export class MeasureFilterManager { } public reconcile(expr: V1Expression | undefined) { - const dimension = expr?.cond?.exprs?.[0]?.ident; - const firstValueExpr = expr?.cond?.exprs?.[1]; + const dimension = expr?.subquery?.dimension; const unwrappedHavingFilter = removeWrapperAndOrExpression( - firstValueExpr?.subquery?.having, + expr?.subquery?.having, ); const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter); @@ -58,7 +60,9 @@ export class MeasureFilterManager { } public clear() { - this.expr = undefined; + this.value1 = ""; + this.value2 = ""; + this.dimension = ""; this.commit(); } @@ -74,5 +78,6 @@ export class MeasureFilterManager { 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/state-managers/loaders/DashboardStateSync.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts index b4de9302d9b5..2ca910e3184e 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -20,7 +20,6 @@ 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts index afa459e0fc5d..2172c354caae 100644 --- a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -11,6 +11,10 @@ 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"; +export type MetricsViewName = string; +export type DimensionName = string; +export type MeasureName = string; + /** * Reactive view over a set of metrics views. * @@ -24,22 +28,24 @@ import { Duration } from "luxon"; */ export class MetricsViewsProvider { /** Valid spec per metrics view name. Absent while the resource is loading or invalid. */ - public specs = $state>({}); + public specs = $state>({}); /** Time range summary per metrics view name. Absent for metrics views without a time dimension. */ - public timeRangeSummaries = $state>({}); + 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> - >({}); - /** Dimension spec per metrics view, keyed by dimension name (or column when unnamed). */ - public dimensionSpecs = $state< - Record> + Record> >({}); /** Deduped by name across metrics views; the first metrics view to define a name wins. */ From 8d34ca410cce7fa4d4e6c9ee8d8499855b8fc390 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 5 Aug 2026 19:34:29 +0530 Subject: [PATCH 13/21] Fix url sync in canvas --- .../canvas/components/BaseCanvasComponent.ts | 2 +- .../canvas/filters/CanvasFilters.svelte | 14 ++++--- .../features/canvas/stores/canvas-entity.ts | 10 +++-- .../filters/ExpressionFilterManager.svelte.ts | 8 ++++ .../filters/ExpressionFilterURLSync.svelte.ts | 37 +++++++++++++++++++ .../dashboards/filters/Filters.svelte | 22 +++++++---- .../loaders/DashboardStateSync.ts | 2 + .../providers/MetricsViewsProvider.svelte.ts | 18 +++++++-- 8 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index 640d94f941ee..b9eea5ce711a 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -158,7 +158,7 @@ export abstract class BaseCanvasComponent { this.unsubscribeSpec = this.specStore.subscribe((spec) => { this.localExpressionFilters.setParamForMetricsView( this.metricsViewName, - spec["dimension_filters"] as string, + (spec["dimension_filters"] ?? "") as string, ); this.localTimeControls.onUrlChange( diff --git a/web-common/src/features/canvas/filters/CanvasFilters.svelte b/web-common/src/features/canvas/filters/CanvasFilters.svelte index cf9c1939b9f9..7fe2b4897c9e 100644 --- a/web-common/src/features/canvas/filters/CanvasFilters.svelte +++ b/web-common/src/features/canvas/filters/CanvasFilters.svelte @@ -183,10 +183,12 @@
{/if} - +
+ +
diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index afec8a77c88a..06c3178f1a5c 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -57,6 +57,7 @@ import { YAMLConfigProvider, } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { ExpressionFilterURLSync } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts"; export const lastVisitedState = new Map(); @@ -210,8 +211,6 @@ export class CanvasEntity { // override is applied. this.themeName.set(undefined); - this.processSpec(this.spec); - this.metricsView = new MetricsViewSelectors( this.client, this._metricsViews, @@ -223,6 +222,10 @@ export class CanvasEntity { this.metricsViewsProvider, this.yamlConfigProvider, ); + // Create the URL sync component + new ExpressionFilterURLSync(this.expressionFilterManager); + + this.processSpec(this.spec); } checkAndSetMaxWidth = ({ maxWidth }: V1CanvasSpec) => { @@ -295,7 +298,7 @@ export class CanvasEntity { if (!validSpec) return; if (metricsViews) this._metricsViews.set(metricsViews); - this.metricsViewsProvider.setMetricsViewNames(Object.keys(metricsViews)); + this.metricsViewsProvider?.setMetricsViewNames(Object.keys(metricsViews)); this.checkAndSetFilterEnabled(validSpec); this.checkAndSetFileArtifact(filePath); @@ -499,7 +502,6 @@ export class CanvasEntity { if (redirected) return; - this.filterManager.onUrlChange(searchParams); this.searchParams.set(searchParams); this.saveSnapshot(searchParams.toString()); this.timeManager.state.onUrlChange(searchParams); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 35704655e32c..0eea7e9433fc 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -29,6 +29,7 @@ import { type DimensionOrMeasureManager, MetricsViewFilterManager, } from "@rilldata/web-common/features/dashboards/filters/MetricsViewFilterManager.svelte.ts"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; type ParsedUrlParamsByMV = Record< MetricsViewName, @@ -50,6 +51,7 @@ export class ExpressionFilterManager { // Temporary svelte4 store for legacy components public exprByMetricsViewStore: Writable> = writable({}); + public inList: string[]; public hasSomeFilter: boolean; // Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. @@ -96,6 +98,11 @@ export class ExpressionFilterManager { this.filterManagers = $derived.by(() => this.buildFilterManagers(this.buildParamFilterManagers()), ); + this.inList = $derived( + this.filterManagers.dimensions + .filter((dfm) => dfm.mode === DimensionFilterMode.InList) + .map((dfm) => dfm.name), + ); this.filterManagersMap = $derived.by(() => Object.fromEntries([ ...this.filterManagers.dimensions.map( @@ -143,6 +150,7 @@ export class ExpressionFilterManager { }); const newParam = newParams.join("&"); + console.log("setUrlParams", newParam); if (this.prevUrlParams === newParam) return; this.prevUrlParams = newParam; this.parsedUrlParams = newParsedUrlParams; diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts new file mode 100644 index 000000000000..fb0655ebc426 --- /dev/null +++ b/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts @@ -0,0 +1,37 @@ +import { page } from "$app/state"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; +import { goto } from "$app/navigation"; + +export class ExpressionFilterURLSync { + private prevUrlSearch = ""; + + public constructor(expressionFilterManager: ExpressionFilterManager) { + $effect(() => expressionFilterManager.setUrlParams(page.url.searchParams)); + $effect(() => { + const newUrlSearchParams = new URLSearchParams(page.url.searchParams); + + expressionFilterManager.metricsViewsProvider.metricsViewNames.forEach( + (mvName) => { + const key = `${ExploreStateURLParams.Filters}.${mvName}`; + if (mvName in expressionFilterManager.exprByMetricsView) { + const param = convertExpressionToFilterParam( + expressionFilterManager.exprByMetricsView[mvName], + expressionFilterManager.inList, + ); + newUrlSearchParams.set(key, param); + } else { + newUrlSearchParams.delete(key); + } + }, + ); + + const newUrlSearch = newUrlSearchParams.toString(); + if (newUrlSearch === this.prevUrlSearch) return; + + this.prevUrlSearch = newUrlSearch; + void goto(`?${newUrlSearch}`); + }); + } +} diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 2e2bb443c69f..386f8042112b 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -41,6 +41,7 @@ import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import ExpressionFilters from "./ExpressionFilters.svelte"; import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; + import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; const { rillTime } = featureFlags; @@ -112,14 +113,19 @@ let exploreState = $derived(useExploreState($exploreName)); let activeTimeZone = $derived($exploreState?.selectedTimezone); - $effect(() => - setFilter( - expressionFilterManager.exprByMetricsView[metricsViewName], - expressionFilterManager.filterManagers.dimensions - .filter((dfm) => dfm.expr && dfm.mode === DimensionFilterMode.InList) - .map((dfm) => dfm.name), - ), - ); + // There seems to be an infinite loop here. Checking synced param should be temporary. + // TODO: fix it by making sure we do not trigger effect. + let prevSyncedUrlParam = ""; + $effect(() => { + const expr = expressionFilterManager.exprByMetricsView[metricsViewName]; + const inList = expressionFilterManager.inList; + + const urlParam = convertExpressionToFilterParam(expr, inList); + if (urlParam === prevSyncedUrlParam) return; + + prevSyncedUrlParam = urlParam; + setFilter(expr, inList); + }); let selectedRangeAlias = $derived( selectedTimeRange?.name === TimeRangePreset.CUSTOM 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 2ca910e3184e..d2da91238f1b 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -213,6 +213,7 @@ export class DashboardStateSync { urlSearchParams: URLSearchParams, type: AfterNavigate["type"], ) { + console.log("handleURLChange", type); // Since we call this in afterNavigation, there could be a scenario where navigation completes but data for init isnt loaded yet. // Init already incorporates the url into the state so we can skip this processing. if (this.updating || !this.initialized) return; @@ -319,6 +320,7 @@ export class DashboardStateSync { * This will check if the url needs to be changed and will navigate to the new url. */ private async gotoNewState(exploreState: ExploreState) { + console.log("gotoNewState"); // Updating state either in handleExploreInit or handleURLChange will synchronously update the state triggering this function. // Since those methods handle redirect themselves we need to skip this logic. // Those methods need to replace the current URL while this does a direct navigation. diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts index 2172c354caae..e7cc264dcd31 100644 --- a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -10,6 +10,7 @@ import { 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"; export type MetricsViewName = string; export type DimensionName = string; @@ -63,6 +64,7 @@ export class MetricsViewsProvider { public cleanup: () => void; // TODO: ensure this is called by creators + private resources: V1Resource[] = []; private readonly timeRangeUnsubs = new Map void>(); public constructor( @@ -74,9 +76,14 @@ export class MetricsViewsProvider { const allResourcesQuery = createRuntimeServiceListResources( runtimeClient, {}, + undefined, + queryClient, ); - const allResourcesUnsub = allResourcesQuery.subscribe((allResourcesResp) => - this.processResources(allResourcesResp.data?.resources ?? []), + const allResourcesUnsub = allResourcesQuery.subscribe( + (allResourcesResp) => { + this.resources = allResourcesResp.data?.resources ?? []; + this.processResources(); + }, ); this.timeRangeSummary = $derived.by(() => { @@ -146,9 +153,10 @@ export class MetricsViewsProvider { public setMetricsViewNames(metricsViewNames: string[]) { this.metricsViewNames = metricsViewNames; + this.processResources(); } - private processResources(resources: V1Resource[]) { + private processResources() { const specs: Record = {}; const measureSpecs: Record< @@ -165,7 +173,7 @@ export class MetricsViewsProvider { const dimensions: MetricsViewSpecDimension[] = []; for (const metricsViewName of this.metricsViewNames) { - const spec = resources.find( + const spec = this.resources.find( (resource) => resource.meta?.name?.name === metricsViewName, )?.metricsView?.state?.validSpec; if (!spec) continue; @@ -222,6 +230,8 @@ export class MetricsViewsProvider { const timeRangeQuery = createQueryServiceMetricsViewTimeRange( this.runtimeClient, { metricsViewName }, + undefined, + queryClient, ); this.timeRangeUnsubs.set( metricsViewName, From c0351659965411611bf21f3ad5308cb9cbaf4ce7 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Thu, 6 Aug 2026 12:16:22 +0530 Subject: [PATCH 14/21] Remove canvas specific expression filter code --- .../src/features/canvas/CanvasBuilder.svelte | 2 +- .../canvas/CanvasDashboardWrapper.svelte | 7 +- .../canvas/components/BaseCanvasComponent.ts | 8 +- .../leaderboard/LeaderboardDisplay.svelte | 199 +-- .../pivot/pivot-click-to-filter.spec.ts | 137 +- .../components/pivot/pivot-test-data.ts | 12 + .../filters/canvas-filter-expressions.ts | 17 +- .../inspector/DefaultFilterDisplay.svelte | 54 +- .../features/canvas/stores/canvas-entity.ts | 54 +- .../features/canvas/stores/filter-manager.ts | 1134 ----------------- .../features/canvas/stores/filter-state.ts | 649 ---------- .../filters/CanvasFilterChipsReadOnly.svelte | 75 -- .../providers/YAMLConfigProvider.svelte.ts | 6 +- .../dashboards/stores/filter-utils.ts | 57 +- .../BaseScheduledReportForm.svelte | 8 +- 15 files changed, 287 insertions(+), 2132 deletions(-) delete mode 100644 web-common/src/features/canvas/stores/filter-manager.ts delete mode 100644 web-common/src/features/canvas/stores/filter-state.ts delete mode 100644 web-common/src/features/dashboards/filters/CanvasFilterChipsReadOnly.svelte diff --git a/web-common/src/features/canvas/CanvasBuilder.svelte b/web-common/src/features/canvas/CanvasBuilder.svelte index 8e76a07471b3..3711f06e090c 100644 --- a/web-common/src/features/canvas/CanvasBuilder.svelte +++ b/web-common/src/features/canvas/CanvasBuilder.svelte @@ -64,7 +64,7 @@ const MIN_DRAG_DISTANCE = 8; let initialMousePosition: { x: number; y: number } | null = null; - let clientWidth: number; + let clientWidth: number = 0; let offset = { x: 0, y: 0 }; let dragComponent: BaseCanvasComponent | null = null; let pendingDragComponent: BaseCanvasComponent | null = null; diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index ac6b0f420da4..44dff951e101 100644 --- a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte +++ b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte @@ -17,7 +17,7 @@ children, canvasName, maxWidth, - clientWidth = 0, + clientWidth = $bindable(0), showGrabCursor = false, filtersEnabled = undefined, embedded = false, @@ -27,7 +27,7 @@ children: Snippet; canvasName: string; maxWidth: number; - clientWidth?: number; + clientWidth: number; showGrabCursor?: boolean; filtersEnabled?: boolean | undefined; embedded?: boolean; @@ -46,6 +46,9 @@ yamlConfigProvider, }, } = $derived(getCanvasStore(canvasName, instanceId)); + $effect(() => { + yamlConfigProvider.setEditable(builder); + }); let missingRequiredFilters = $derived( getMissingRequiredFilters(expressionFilterManager, yamlConfigProvider), diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index b9eea5ce711a..1d6a8185b598 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -14,7 +14,7 @@ import type { V1Resource, V1TimeRange, } from "@rilldata/web-common/runtime-client"; -import type { ComponentType, SvelteComponent } from "svelte"; +import type { Component, ComponentType, SvelteComponent } from "svelte"; import type { Readable, Unsubscriber } from "svelte/store"; import { derived, get, writable, type Writable } from "svelte/store"; import { mergeFilters } from "../../dashboards/pivot/pivot-merge-filters"; @@ -64,8 +64,10 @@ export abstract class BaseCanvasComponent { private unsubscribeSpec: Unsubscriber; abstract type: CanvasComponentType; - // Component responsible for DOM rendering - abstract component: ComponentType; + // Component responsible for DOM rendering. + // The union covers both Svelte 4 class components and Svelte 5 (runes) components, + // since the display components are being migrated one at a time. + abstract component: Component | ComponentType; // Will be deprecated abstract minSize: ComponentSize; // Will be deprecated diff --git a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte index 149f5a00fb59..1afdf3c6550f 100644 --- a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte @@ -12,7 +12,6 @@ import { SortDirection } from "@rilldata/web-common/features/dashboards/proto-state/derived-types"; import { selectedDimensionValues } from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimension-filters"; import { createMeasureValueFormatter } from "@rilldata/web-common/lib/number-formatting/format-measure-value"; - import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import ComponentHeader from "../../ComponentHeader.svelte"; import { @@ -21,123 +20,125 @@ MIN_DIMENSION_COLUMN_WIDTH, } from "./util"; - export let component: LeaderboardComponent; + let { component }: { component: LeaderboardComponent } = $props(); const runtimeClient = useRuntimeClient(); + const { instanceId } = runtimeClient; - let metricsViewName: string; - let leaderboardMeasureNames: string[] = []; - let dimensionNames: string[] = []; - let numRows = 7; - let leaderboardWrapperWidth = 0; + let leaderboardWrapperWidth = $state(0); - $: ({ instanceId } = runtimeClient); + let specStore = $derived(component.specStore); + let timeAndFilterStore = $derived(component.timeAndFilterStore); + let leaderboardState = $derived(component.leaderboardState); + let toggleSort = $derived(component.toggleSort); + let dataEnabled = $derived(component.dataEnabled); - $: ({ - specStore, - timeAndFilterStore, - leaderboardState, - toggleSort, - parent: { name: canvasName }, - dataEnabled: visible, - } = component); - $: leaderboardProperties = $specStore; + let leaderboardProperties = $derived($specStore); - $: store = getCanvasStore(canvasName, instanceId); - $: ({ - canvasEntity: { - metricsView: { - getMetricsViewFromName, - getDimensionsForMetricView, - getMeasuresForMetricView, - }, - expressionFilterManager, - }, - } = store); - - $: { - metricsViewName = leaderboardProperties.metrics_view; - leaderboardMeasureNames = leaderboardProperties.measures ?? []; - dimensionNames = leaderboardProperties.dimensions ?? []; - numRows = leaderboardProperties.num_rows ?? 7; - } + let store = $derived(getCanvasStore(component.parent.name, instanceId)); + let metricsViewSelectors = $derived(store.canvasEntity.metricsView); + let expressionFilterManager = $derived( + store.canvasEntity.expressionFilterManager, + ); - $: metricsViewQuery = getMetricsViewFromName(metricsViewName); + let metricsViewName = $derived(leaderboardProperties.metrics_view); + let leaderboardMeasureNames = $derived(leaderboardProperties.measures ?? []); + let dimensionNames = $derived(leaderboardProperties.dimensions ?? []); + let numRows = $derived(leaderboardProperties.num_rows ?? 7); - $: schema = validateLeaderboardSchema( - leaderboardProperties, - $metricsViewQuery, + let metricsViewQuery = $derived( + metricsViewSelectors.getMetricsViewFromName(metricsViewName), ); - $: ({ showTimeComparison, comparisonTimeRange, timeRange, where } = - $timeAndFilterStore); + let schema = $derived( + validateLeaderboardSchema(leaderboardProperties, $metricsViewQuery), + ); - $: ({ dimensionFilters: whereFilter, dimensionThresholdFilters } = - splitWhereFilter(where)); + let showTimeComparison = $derived($timeAndFilterStore.showTimeComparison); + let comparisonTimeRange = $derived($timeAndFilterStore.comparisonTimeRange); + let timeRange = $derived($timeAndFilterStore.timeRange); - $: allDimensions = getDimensionsForMetricView(metricsViewName); - $: allMeasures = getMeasuresForMetricView(metricsViewName); + let splitFilters = $derived(splitWhereFilter($timeAndFilterStore.where)); + let whereFilter = $derived(splitFilters.dimensionFilters); + let dimensionThresholdFilters = $derived( + splitFilters.dimensionThresholdFilters, + ); - $: visibleDimensions = dimensionNames - .map((name) => - $allDimensions.find((d) => (d.name || (d.column as string)) === name), - ) - .filter((d) => d !== undefined); + let allDimensions = $derived( + metricsViewSelectors.getDimensionsForMetricView(metricsViewName), + ); + let allMeasures = $derived( + metricsViewSelectors.getMeasuresForMetricView(metricsViewName), + ); - $: visibleMeasures = leaderboardMeasureNames - .map((lm) => $allMeasures.find((m) => m.name === lm)) - .filter(Boolean) as MetricsViewSpecMeasure[]; + let visibleDimensions = $derived( + dimensionNames + .map((name) => + $allDimensions.find((d) => (d.name || (d.column as string)) === name), + ) + .filter((d) => d !== undefined), + ); - $: measureFormatters = Object.fromEntries( - visibleMeasures.map((m) => [ - m.name, - createMeasureValueFormatter(m), - ]), + let visibleMeasures = $derived( + leaderboardMeasureNames + .map((lm) => $allMeasures.find((m) => m.name === lm)) + .filter((m) => m !== undefined), ); - $: measureTooltipFormatters = Object.fromEntries( - visibleMeasures.map((m) => [ - m.name, - createMeasureValueFormatter(m, "tooltip"), - ]), + let measureFormatters = $derived( + Object.fromEntries( + visibleMeasures.map((m) => [ + m.name, + createMeasureValueFormatter(m), + ]), + ), ); - // Reset column widths when the measure changes - $: if (leaderboardMeasureNames) { - valueColumn.reset(); - } + let measureTooltipFormatters = $derived( + Object.fromEntries( + visibleMeasures.map((m) => [ + m.name, + createMeasureValueFormatter(m, "tooltip"), + ]), + ), + ); - $: totalContextWidth = leaderboardMeasureNames.reduce( - (sum, measureName) => - sum + - $valueColumn + - (showTimeComparison ? COMPARISON_COLUMN_WIDTH * 2 : 0) + - (isValidPercentOfTotal(measureName) ? COMPARISON_COLUMN_WIDTH : 0), - 0, + let totalContextWidth = $derived( + leaderboardMeasureNames.reduce( + (sum, measureName) => + sum + + $valueColumn + + (showTimeComparison ? COMPARISON_COLUMN_WIDTH * 2 : 0) + + (isValidPercentOfTotal(measureName) ? COMPARISON_COLUMN_WIDTH : 0), + 0, + ), ); - $: dimensionColumnWidth = getDimensionColumnWidth( - leaderboardWrapperWidth, - totalContextWidth, + let dimensionColumnWidth = $derived( + getDimensionColumnWidth(leaderboardWrapperWidth, totalContextWidth), ); - $: estimatedTableWidth = MIN_DIMENSION_COLUMN_WIDTH + totalContextWidth; + let estimatedTableWidth = $derived( + MIN_DIMENSION_COLUMN_WIDTH + totalContextWidth, + ); - $: ({ - title, - description, - show_description_as_tooltip, - time_filters, - dimension_filters, - } = leaderboardProperties); + let filters = $derived({ + time_filters: leaderboardProperties.time_filters, + dimension_filters: leaderboardProperties.dimension_filters, + }); - $: filters = { - time_filters, - dimension_filters, - }; + let leaderboardSortByMeasureName = $derived( + $leaderboardState.leaderboardSortByMeasureName, + ); + let sortDirection = $derived($leaderboardState.sortDirection); + let sortType = $derived($leaderboardState.sortType); - $: ({ leaderboardSortByMeasureName, sortDirection, sortType } = - $leaderboardState); + // Reset column widths when the measures change. Uses `$effect.pre` so the reset + // lands before the DOM update, keeping the width derivations above in sync for + // the same render rather than one frame later. + $effect.pre(() => { + if (leaderboardMeasureNames) valueColumn.reset(); + }); function isValidPercentOfTotal(measureName: string) { return ( @@ -150,15 +151,17 @@ {#if schema.isValid}
= {}, - whereFilter?: V1Expression, + globalFilter?: V1Expression, ) { const selfFilteredDimensions = writable>(new Set()); const fm = createFilterManager(); + if (globalFilter) { + fm.setExprForMetricsView(PIVOT_TEST_METRICS_NAME, globalFilter); + } const result = createPivotClickToFilter( createFactoryArgs({ @@ -164,7 +174,7 @@ function setup( filterManager: fm, activeComponent: writable("pivot-1"), selfFilteredDimensions, - whereFilterStore: writable(whereFilter), + whereFilterStore: writable(globalFilter), }), ); @@ -653,16 +663,10 @@ describe("unrelated global filters are left alone", () => { createInExpression("device", ["mobile"]), ]); - /** Assert a dimension was neither added to nor removed from the filter set. */ - function expectUntouched( - filterClass: ReturnType["filterClass"], - dimensionName: string, - ) { - expectNoToggle(filterClass, dimensionName); - const addCalls = filterClass.addDimensionValueSelections.mock.calls.filter( - (call: unknown[]) => call[0] === dimensionName, - ); - expect(addCalls).toHaveLength(0); + /** Assert the global filter's values are intact: neither dropped nor duplicated. */ + function expectGlobalFilterIntact(fm: ExpressionFilterManager) { + expect(selectedValues(fm, "domain")).toEqual(["google.com"]); + expect(selectedValues(fm, "device")).toEqual(["mobile"]); } const flatConfig = makeConfig({ @@ -674,21 +678,12 @@ describe("unrelated global filters are left alone", () => { const flatData: PivotDataRow[] = [{ country: "US", revenue: 100 }]; it("does not re-add global filter values when selecting a cell", () => { - const { result, filterClass } = setup( - flatConfig, - flatData, - {}, - globalFilter, - ); + const { result, fm } = setup(flatConfig, flatData, {}, globalFilter); result.handleCellClickToFilter("0", "revenue", false, flatData[0]); - expect(filterClass.addDimensionValueSelections).toHaveBeenCalledWith( - "country", - ["US"], - ); - expectUntouched(filterClass, "domain"); - expectUntouched(filterClass, "device"); + expect(selectedValues(fm, "country")).toEqual(["US"]); + expectGlobalFilterIntact(fm); result.destroy(); }); @@ -709,27 +704,14 @@ describe("unrelated global filters are left alone", () => { }); it("keeps global filters when deselecting a cell", () => { - const { result, filterClass } = setup( - flatConfig, - flatData, - {}, - globalFilter, - ); + const { result, fm } = setup(flatConfig, flatData, {}, globalFilter); result.handleCellClickToFilter("0", "revenue", false, flatData[0]); - filterClass.toggleDimensionValueSelections.mockClear(); - filterClass.addDimensionValueSelections.mockClear(); result.handleCellClickToFilter("0", "revenue", false, flatData[0]); expect(sel(result).cellSelections.size).toBe(0); - expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( - "country", - ["US"], - false, - false, - ); - expectUntouched(filterClass, "domain"); - expectUntouched(filterClass, "device"); + expect(selectedValues(fm, "country")).toEqual([]); + expectGlobalFilterIntact(fm); result.destroy(); }); @@ -742,29 +724,17 @@ describe("unrelated global filters are left alone", () => { }); const nestedData: PivotDataRow[] = [{ country: "US", revenue: 100 }]; - const { result, filterClass } = setup( - nestedConfig, - nestedData, - {}, - globalFilter, - ); + const { result, fm } = setup(nestedConfig, nestedData, {}, globalFilter); result.handleCellClickToFilter("1", "country", true, nestedData[0]); expect(sel(result).rowHeaderSelections.size).toBe(1); + expect(selectedValues(fm, "country")).toEqual(["US"]); - filterClass.toggleDimensionValueSelections.mockClear(); - filterClass.addDimensionValueSelections.mockClear(); result.handleCellClickToFilter("1", "country", true, nestedData[0]); expect(sel(result).rowHeaderSelections.size).toBe(0); - expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( - "country", - ["US"], - false, - false, - ); - expectUntouched(filterClass, "domain"); - expectUntouched(filterClass, "device"); + expect(selectedValues(fm, "country")).toEqual([]); + expectGlobalFilterIntact(fm); result.destroy(); }); @@ -777,22 +747,16 @@ describe("unrelated global filters are left alone", () => { whereFilter: globalFilter, }); - const { result, filterClass } = setup(colConfig, [], {}, globalFilter); + const { result, fm } = setup(colConfig, [], {}, globalFilter); result.handleColumnHeaderClick({ region: "NA" }); - filterClass.toggleDimensionValueSelections.mockClear(); - filterClass.addDimensionValueSelections.mockClear(); + expect(selectedValues(fm, "region")).toEqual(["NA"]); + result.handleColumnHeaderClick({ region: "NA" }); expect(sel(result).columnHeaderSelections.size).toBe(0); - expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( - "region", - ["NA"], - false, - false, - ); - expectUntouched(filterClass, "domain"); - expectUntouched(filterClass, "device"); + expect(selectedValues(fm, "region")).toEqual([]); + expectGlobalFilterIntact(fm); result.destroy(); }); @@ -805,19 +769,14 @@ describe("unrelated global filters are left alone", () => { whereFilter: globalFilter, }); - const { result, filterClass } = setup(colConfig, [], {}, globalFilter); + const { result, fm } = setup(colConfig, [], {}, globalFilter); result.handleColumnHeaderClick({ region: "NA" }); - filterClass.toggleDimensionValueSelections.mockClear(); - filterClass.addDimensionValueSelections.mockClear(); result.handleColumnHeaderClick({ region: "NA", category: "Electronics" }); - expect(filterClass.addDimensionValueSelections).toHaveBeenCalledWith( - "category", - ["Electronics"], - ); - expectUntouched(filterClass, "domain"); - expectUntouched(filterClass, "device"); + expect(selectedValues(fm, "region")).toEqual(["NA"]); + expect(selectedValues(fm, "category")).toEqual(["Electronics"]); + expectGlobalFilterIntact(fm); result.destroy(); }); @@ -836,12 +795,7 @@ describe("unrelated global filters are left alone", () => { }, ]; - const { result, filterClass } = setup( - nestedConfig, - nestedData, - {}, - globalFilter, - ); + const { result, fm } = setup(nestedConfig, nestedData, {}, globalFilter); result.handleCellClickToFilter( "1.0", @@ -849,18 +803,15 @@ describe("unrelated global filters are left alone", () => { false, nestedData[0].subRows![0], ); - filterClass.toggleDimensionValueSelections.mockClear(); - filterClass.addDimensionValueSelections.mockClear(); + expect(selectedValues(fm, "inner")).toEqual(["US-East"]); + result.handleCellClickToFilter("1", "outer", true, nestedData[0]); - expect(filterClass.toggleDimensionValueSelections).toHaveBeenCalledWith( - "inner", - ["US-East"], - false, - false, - ); - expectUntouched(filterClass, "domain"); - expectUntouched(filterClass, "device"); + // The evicted child cell's inner value is dropped, the clicked row header's + // outer value takes its place, and the global filter is untouched. + expect(selectedValues(fm, "inner")).toEqual([]); + expect(selectedValues(fm, "outer")).toEqual(["Zoom"]); + expectGlobalFilterIntact(fm); result.destroy(); }); diff --git a/web-common/src/features/canvas/components/pivot/pivot-test-data.ts b/web-common/src/features/canvas/components/pivot/pivot-test-data.ts index 0bbd16c5fc10..8820c6b18c8f 100644 --- a/web-common/src/features/canvas/components/pivot/pivot-test-data.ts +++ b/web-common/src/features/canvas/components/pivot/pivot-test-data.ts @@ -20,6 +20,10 @@ export const PIVOT_OUTER_DIMENSION = "outer"; export const PIVOT_INNER_DIMENSION = "inner"; export const PIVOT_BOROUGH_DIMENSION = "borough"; +// Dimensions that only ever appear in a dashboard-level filter, never in a pivot. +export const PIVOT_DOMAIN_DIMENSION = "domain"; +export const PIVOT_DEVICE_DIMENSION = "device"; + // Column dimensions. export const PIVOT_REGION_DIMENSION = "region"; export const PIVOT_CATEGORY_DIMENSION = "category"; @@ -93,6 +97,14 @@ export const PIVOT_DIMENSIONS: MetricsViewSpecDimension[] = [ name: PIVOT_ENV_DIMENSION, dataType: { code: TypeCode.CODE_STRING }, }, + { + name: PIVOT_DOMAIN_DIMENSION, + dataType: { code: TypeCode.CODE_STRING }, + }, + { + name: PIVOT_DEVICE_DIMENSION, + dataType: { code: TypeCode.CODE_STRING }, + }, ]; /** diff --git a/web-common/src/features/canvas/filters/canvas-filter-expressions.ts b/web-common/src/features/canvas/filters/canvas-filter-expressions.ts index c2dcce8ef2c6..42d7235dca9a 100644 --- a/web-common/src/features/canvas/filters/canvas-filter-expressions.ts +++ b/web-common/src/features/canvas/filters/canvas-filter-expressions.ts @@ -1,6 +1,5 @@ import type { CanvasEntity } from "@rilldata/web-common/features/canvas/stores/canvas-entity"; import type { V1Expression } from "@rilldata/web-common/runtime-client"; -import { get } from "svelte/store"; /** * Extracts filters from a canvas entity, scoped per metrics view. @@ -9,10 +8,10 @@ import { get } from "svelte/store"; export function getCanvasFilters( canvasEntity: CanvasEntity, ): Record | undefined { - const filtersMap = get(canvasEntity.filterManager.filterMapStore); + const filtersMap = canvasEntity.expressionFilterManager.exprByMetricsView; // Check if there are any non-empty filters - const hasFilters = Array.from(filtersMap.values()).some( + const hasFilters = Object.values(filtersMap).some( (expr) => expr?.cond?.exprs && expr.cond.exprs.length > 0, ); @@ -22,7 +21,7 @@ export function getCanvasFilters( // Convert Map to plain object for API const metricsViewFilters: Record = {}; - filtersMap.forEach((expr, metricsViewName) => { + Object.entries(filtersMap).forEach(([metricsViewName, expr]) => { if (expr?.cond?.exprs && expr.cond.exprs.length > 0) { metricsViewFilters[metricsViewName] = expr; } @@ -32,13 +31,3 @@ export function getCanvasFilters( ? metricsViewFilters : undefined; } - -/** - * Checks if the canvas has any active filters. - */ -export function hasCanvasFilters(canvasEntity: CanvasEntity): boolean { - const filtersMap = get(canvasEntity.filterManager.filterMapStore); - return Array.from(filtersMap.values()).some( - (expr) => expr?.cond?.exprs && expr.cond.exprs.length > 0, - ); -} diff --git a/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte b/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte index edae0c35984f..c2f6123dfef5 100644 --- a/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte +++ b/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte @@ -3,31 +3,53 @@ import { getCanvasStore } from "../state-managers/state-managers"; import Button from "@rilldata/web-common/components/button/Button.svelte"; import Trash from "@rilldata/web-common/components/icons/Trash.svelte"; - import CanvasFilterChipsReadOnly from "../../dashboards/filters/CanvasFilterChipsReadOnly.svelte"; + import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; + import ReadonlyExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ReadonlyExpressionFilters.svelte"; + import type { V1TimeRange } from "@rilldata/web-common/runtime-client"; - export let canvasName: string; + let { canvasName }: { canvasName: string } = $props(); const runtimeClient = useRuntimeClient(); - $: ({ instanceId } = runtimeClient); - - $: ({ + let { canvasEntity: { + specStore, clearDefaultFilters, - filterManager: { defaultUIFiltersStore }, timeManager: { state: { interval: _interval }, defaultTimeRangeStore, defaultComparisonRangeStore, }, + metricsViewsProvider, + yamlConfigProvider, }, - } = getCanvasStore(canvasName, instanceId)); + } = $derived(getCanvasStore(canvasName, runtimeClient.instanceId)); - $: interval = $_interval; + let interval = $derived($_interval); - $: defaultTimeRange = $defaultTimeRangeStore; + let defaultTimeRange: V1TimeRange | undefined = $defaultTimeRangeStore + ? { + expression: $defaultTimeRangeStore, + } + : undefined; + let defaultComparisonRange: V1TimeRange | undefined = + $defaultComparisonRangeStore + ? { + expression: $defaultComparisonRangeStore, + } + : undefined; - $: defaultComparisonRange = $defaultComparisonRangeStore; + let defaultFiltersManager = $derived( + new ExpressionFilterManager(metricsViewsProvider, yamlConfigProvider), + ); + $effect(() => { + if (!$specStore.data?.canvas?.defaultPreset?.filterExpr) return; + Object.entries($specStore.data?.canvas?.defaultPreset?.filterExpr).forEach( + ([key, value]) => { + defaultFiltersManager.setExprForMetricsView(key, value?.expression); + }, + ); + });
@@ -37,12 +59,12 @@ automatically apply each time you open this dashboard in Rill Cloud.

-
diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index 1f125406f941..65812e1c12a0 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -38,8 +38,6 @@ import { isChartComponentType, isTableComponentType, } from "../components/util"; -import { FilterManager, flattenExpression } from "./filter-manager"; -import { getFilterParam } from "./filter-state"; import { Grid } from "./grid"; import { TabGroup, type LayoutBlock } from "./tab-group"; import { getComparisonTypeFromRangeString } from "./time-state"; @@ -58,6 +56,8 @@ import { } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; import { ExpressionFilterURLSync } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts"; +import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; +import { flattenExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; export const lastVisitedState = new Map(); @@ -98,9 +98,6 @@ export class CanvasEntity { // Time state controls timeManager: TimeManager; - // Dimension and measure filter state - filterManager: FilterManager; - // Metrics view selectors metricsView: MetricsViewSelectors; metricsViewsProvider: MetricsViewsProvider; @@ -302,7 +299,7 @@ export class CanvasEntity { if (!validSpec) return; if (metricsViews) this._metricsViews.set(metricsViews); - this.metricsViewsProvider?.setMetricsViewNames(Object.keys(metricsViews)); + this.metricsViewsProvider.setMetricsViewNames(Object.keys(metricsViews)); this.checkAndSetFilterEnabled(validSpec); this.checkAndSetFileArtifact(filePath); @@ -315,38 +312,7 @@ export class CanvasEntity { this.titleStore.set(validSpec.displayName ?? ""); - const defaultPreset = validSpec?.defaultPreset ?? {}; - const filterExpressions = defaultPreset.filterExpr ?? {}; - const pinnedFilters = validSpec?.pinnedFilters ?? []; - const requiredFilters = validSpec?.requiredFilters ?? []; - - if (metricsViews) { - if (this.filterManager) { - this.filterManager.updateConfig( - metricsViews, - pinnedFilters, - filterExpressions, - requiredFilters, - ); - } else { - this.filterManager = new FilterManager( - metricsViews, - this.instanceId, - pinnedFilters, - filterExpressions, - requiredFilters, - ); - // Clears the active component when a global filter changes through - // FilterManager.actions.* (user-driven filter UI). Pivot click-to-filter - // bypasses actions and mutates FilterState directly, so it does NOT - // trigger this callback; see pivot-click-to-filter.ts for details. - this.filterManager.onFilterChange = () => this.clearActiveComponent(); - } - } else { - // need to find a better way to initialize this in certain contextx - bgh - this.filterManager = new FilterManager({}, "", [], {}); - this.filterManager.onFilterChange = () => this.clearActiveComponent(); - } + // TODO: onFilterChange for non-pivot-click-to-filter and call this.clearActiveComponent. this.processRows({ canvas, components, metricsViews, filePath }); }; @@ -367,10 +333,12 @@ export class CanvasEntity { // Persist pinned and required independently. Render-time treats a filter as // visible whenever it's in either set, so we don't dedupe here: doing so // would silently drop the pin flag if a user later toggled required off. - const pinnedNames = Array.from(pinnedFilters).map((f) => f.split("::")[1]); - const requiredNames = Array.from(requiredFilters).map( - (f) => f.split("::")[1], - ); + const pinnedNames = pinnedFilters + .map((f) => f.split("::").pop()) + .filter(Boolean) as string[]; + const requiredNames = requiredFilters + .map((f) => f.split("::").pop()) + .filter(Boolean) as string[]; const timeRange = get(this.timeManager.state.rangeStore); const comparisonOn = get(this.timeManager.state.showTimeComparisonStore); @@ -1058,7 +1026,7 @@ function getDefaults(defaultPreset: V1CanvasPreset) { ([metricsViewName, { expression }]) => { if (expression) { const flattened = flattenExpression(expression); - const urlFormat = getFilterParam(flattened, [], []); + const urlFormat = convertExpressionToFilterParam(flattened, []); if (urlFormat) { defaultSearchParams.set( diff --git a/web-common/src/features/canvas/stores/filter-manager.ts b/web-common/src/features/canvas/stores/filter-manager.ts deleted file mode 100644 index fea046dbb2f9..000000000000 --- a/web-common/src/features/canvas/stores/filter-manager.ts +++ /dev/null @@ -1,1134 +0,0 @@ -import { goto } from "$app/navigation"; -import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants"; -import type { MeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; -import { type DimensionFilterItem } from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimension-filters"; -import type { MeasureFilterItem } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measure-filters"; -import type { - MetricsViewSpecDimension, - V1CanvasPresetFilterExpr, - V1Expression, - V1MetricsView, -} from "@rilldata/web-common/runtime-client"; -import { - V1Operation, - type MetricsViewSpecMeasure, -} from "@rilldata/web-common/runtime-client"; -import { - derived, - get, - writable, - type Readable, - type Writable, -} from "svelte/store"; -import { getDimensionDisplayName } from "../../dashboards/filters/getDisplayName"; -import { - createAndExpression, - flattenInExpressionValues, -} from "../../dashboards/stores/filter-utils"; -import { ExploreStateURLParams } from "../../dashboards/url-state/url-params"; -import type { ParsedFilters } from "./filter-state"; -import { FilterState } from "./filter-state"; - -export type UIFilters = { - dimensionFilters: Map; - measureFilters: Map; - complexFilters: V1Expression[]; - hasFilters: boolean; - hasClearableFilters: boolean; -}; - -export type MissingRequiredFilter = { - key: string; - name: string; - label: string; -}; - -export type MetricsViewName = string; -type DimensionName = string; -type MeasureName = string; - -type LookupKey = `${string}${typeof NAME_SEPARATOR}${string}`; - -// // A Lookup allows you to take a unique dimension/measure key (after namespace merging) -// // And find all the corresponding measures/dimensions across all metrics views -type UniqueLookup = Map>; - -export type DimensionLookup = UniqueLookup; -export type MeasureLookup = UniqueLookup; - -const MV_NAME_SEPARATOR = "//"; -const NAME_SEPARATOR = "::"; - -function getLookupKey(mvNames: string[], name: string): LookupKey { - return `${mvNames.sort().join(MV_NAME_SEPARATOR)}${NAME_SEPARATOR}${name}`; -} - -export class FilterManager { - metricsViewFilters = new StoreOfStores(); - pinnedFilterKeysStore = writable>(new Set()); - defaultPinnedFilterKeysStore = writable>(new Set()); - requiredFilterKeysStore = writable>(new Set()); - defaultRequiredFilterKeysStore = writable>(new Set()); - temporaryFilterKeysStore = writable>(new Map()); - - // Fires when a global filter mutation is committed (via actions or clearAllFilters). - onFilterChange?: () => void; - - allDimensionsStore = writable(new Map()); - allMeasuresStore = writable(new Map()); - - activeUIFiltersStore: Readable; - defaultUIFiltersStore: Readable; - - missingRequiredFiltersStore: Readable; - - filterMapStore: Readable>; - - dimensionsForMetricsView = writable( - new Map>(), - ); - measuresForMetricsView = writable( - new Map>(), - ); - - // Look up list of dimensions based on which combination of metrics views a measure is applicable for - // {{metrics_view_1}}//{{metrics_view_2}} will get you all the dimensions in metrics_view_1 and metrics_view_2 - uniqueMetricsViewGroupingToDimensionArrayStore = writable( - new Map(), - ); - - constructor( - metricsViews: Record, - public instanceId: string, - pinnedFilters?: string[], - defaultFilters?: V1CanvasPresetFilterExpr, - requiredFilters?: string[], - ) { - this.updateConfig( - metricsViews, - pinnedFilters, - defaultFilters, - requiredFilters, - ); - - this.defaultUIFiltersStore = derived( - [this.metricsViewFilters], - ([metricsViewFilters], set) => { - const stores = Array.from(metricsViewFilters.values()).map( - (f) => f.parsedDefaultFilters, - ); - derived( - [ - this.defaultPinnedFilterKeysStore, - this.allMeasuresStore, - this.allDimensionsStore, - ...stores, - ], - ([ - defaultPinnedFilterKeys, - allMeasures, - allDimensions, - ...filters - ]) => { - return this.convertToUIFilters( - filters, - new Map(), - defaultPinnedFilterKeys, - allMeasures, - allDimensions, - ); - }, - ).subscribe(set); - }, - ); - - this.activeUIFiltersStore = derived( - [this.metricsViewFilters], - ([metricsViewFilters], set) => { - const stores = Array.from(metricsViewFilters.values()).map( - (f) => f.parsed, - ); - - derived( - [ - this.pinnedFilterKeysStore, - this.temporaryFilterKeysStore, - this.requiredFilterKeysStore, - this.allMeasuresStore, - this.allDimensionsStore, - ...stores, - ], - ([ - pinnedFilters, - temporaryFilterKeys, - requiredFilters, - allMeasures, - allDimensions, - ...filters - ]) => { - return this.convertToUIFilters( - filters, - temporaryFilterKeys, - pinnedFilters, - allMeasures, - allDimensions, - requiredFilters, - ); - }, - ).subscribe(set); - }, - ); - - this.filterMapStore = derived( - [this.metricsViewFilters], - ([metricsViewFilters], set) => { - const stores = Array.from(metricsViewFilters.values()).map( - (f) => f.parsed, - ); - - derived(stores, (filters) => { - const map = new Map(); - filters.forEach((expr, i) => { - const mvName = Array.from(metricsViewFilters.keys())[i]; - map.set(mvName, expr.where); - }); - return map; - }).subscribe(set); - }, - ); - - this.missingRequiredFiltersStore = derived( - [this.requiredFilterKeysStore, this.activeUIFiltersStore], - ([requiredKeys, activeUIFilters]) => { - if (requiredKeys.size === 0) return []; - - const missing: MissingRequiredFilter[] = []; - requiredKeys.forEach((key) => { - const name = key.split(NAME_SEPARATOR)[1] ?? key; - const dimItem = activeUIFilters.dimensionFilters.get(key); - if (dimItem) { - if (isDimensionItemEmpty(dimItem)) { - missing.push({ key, name, label: dimItem.label || name }); - } - return; - } - - const measureItem = activeUIFilters.measureFilters.get(key); - if (measureItem) { - if (isMeasureItemEmpty(measureItem)) { - missing.push({ key, name, label: measureItem.label || name }); - } - return; - } - - // Required filter has no entry in the UI filter map at all => unsatisfied. - missing.push({ key, name, label: name }); - }); - return missing; - }, - ); - } - - createLocalFilterStore = (metricsViewName: string) => { - return new FilterState(metricsViewName, this, this.instanceId); - }; - - onUrlChange = (searchParams: URLSearchParams) => { - const legacyFilter = searchParams.get(ExploreStateURLParams.Filters); - - this.metricsViewFilters.forEach((filters, mvName) => { - const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; - const filterString = searchParams.get(paramKey) ?? legacyFilter ?? ""; - - filters.onFilterStringChange(filterString); - }); - }; - - updateConfig = ( - metricsViews: Record, - pinnedFilters?: string[], - defaultFilters?: V1CanvasPresetFilterExpr, - requiredFilters?: string[], - ): void => { - const dimensionNameToMetricsViewNames: Map< - DimensionName, - MetricsViewName[] - > = new Map(); - const measureNameToMetricsViewNames: Map = - new Map(); - - const exhaustiveMeasures: Map< - MetricsViewName, - Map - > = new Map(); - const exhaustiveDimensions: Map< - MetricsViewName, - Map - > = new Map(); - - Object.entries(metricsViews).forEach(([metricsViewName, metricsView]) => { - if (!metricsView) return; - - const { measures, dimensions } = metricsView.state?.validSpec || {}; - const dimensionsForMetricsView = new Map< - DimensionName, - MetricsViewSpecDimension - >(); - updateNameToMetricsViews( - dimensions?.filter((d) => d.type !== "DIMENSION_TYPE_TIME"), - (d) => d.name, - dimensionNameToMetricsViewNames, - dimensionsForMetricsView, - metricsViewName, - ); - - exhaustiveDimensions.set(metricsViewName, dimensionsForMetricsView); - - const measuresForMetricsView = new Map< - MeasureName, - MetricsViewSpecMeasure - >(); - updateNameToMetricsViews( - measures, - (m) => m.name, - measureNameToMetricsViewNames, - measuresForMetricsView, - metricsViewName, - ); - - exhaustiveMeasures.set(metricsViewName, measuresForMetricsView); - - let filterStore = this.metricsViewFilters.get(metricsViewName); - if (!filterStore) { - filterStore = new FilterState(metricsViewName, this, this.instanceId); - this.metricsViewFilters.set(metricsViewName, filterStore); - } - - const filter = defaultFilters?.[metricsViewName]; - filterStore.onDefaultExpressionChange( - flattenExpression(filter?.expression), - ); - }); - - const mergedDimensions = mergeFilters( - exhaustiveDimensions, - dimensionNameToMetricsViewNames, - "all", - ); - - const mergedMeasures = mergeFilters( - exhaustiveMeasures, - measureNameToMetricsViewNames, - "all", - ); - - const resolveFilterKey = (filterName: string): string | undefined => { - const metricsViewNames = new Set(); - this.metricsViewFilters.forEach((_, metricsViewName) => { - const dimensionsForView = exhaustiveDimensions.get(metricsViewName); - const measuresForView = exhaustiveMeasures.get(metricsViewName); - if (dimensionsForView?.has(filterName)) { - metricsViewNames.add(metricsViewName); - } - if (measuresForView?.has(filterName)) { - metricsViewNames.add(metricsViewName); - } - }); - if (metricsViewNames.size === 0) return undefined; - return getLookupKey(Array.from(metricsViewNames), filterName); - }; - - const requiredKeys = new Set(); - if (requiredFilters) { - requiredFilters.forEach((filterName) => { - const key = resolveFilterKey(filterName); - if (key) requiredKeys.add(key); - }); - } - this.requiredFilterKeysStore.set(requiredKeys); - this.defaultRequiredFilterKeysStore.set(new Set(requiredKeys)); - - // Pinned and required are tracked independently. convertToUIFilters renders - // a filter as visible whenever it's pinned OR required, so we don't fold - // required into the pinned set here. Doing so would mean toggling required - // off in the UI also drops the pin. - if (pinnedFilters) { - const pinnedKeys = new Set(); - pinnedFilters.forEach((filterName) => { - const key = resolveFilterKey(filterName); - if (key) pinnedKeys.add(key); - }); - this.pinnedFilterKeysStore.set(pinnedKeys); - this.defaultPinnedFilterKeysStore.set(new Set(pinnedKeys)); - } - - // Update stores - this.dimensionsForMetricsView.set(exhaustiveDimensions); - this.measuresForMetricsView.set(exhaustiveMeasures); - - this.allMeasuresStore.set(mergedMeasures); - - this.allDimensionsStore.set(mergedDimensions); - - this.uniqueMetricsViewGroupingToDimensionArrayStore.set( - createDimensionsForUniqueCombinationsOfMetricsViews(exhaustiveDimensions), - ); - }; - - getUIFiltersFromString = (filterString: string) => { - const searchParams = new URLSearchParams(filterString); - - const parsedFilters: ParsedFilters[] = []; - const legacyFilter = searchParams.get(ExploreStateURLParams.Filters); - - this.metricsViewFilters.forEach((filters, mvName) => { - const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; - const filterString = searchParams.get(paramKey) ?? legacyFilter ?? ""; - - const parsed = filters.parseFilterString(filterString); - parsedFilters.push(parsed); - }); - - return this.convertToUIFilters( - parsedFilters, - new Map(), - get(this.pinnedFilterKeysStore), - get(this.allMeasuresStore), - get(this.allDimensionsStore), - get(this.requiredFilterKeysStore), - ); - }; - - convertToUIFilters = ( - parsedFilters: ParsedFilters[], - temporaryFilterKeys: Map, - pinnedFilters: Set, - allMeasures: MeasureLookup, - allDimensions: DimensionLookup, - requiredFilters: Set = new Set(), - ): UIFilters => { - const parsedMap = new Map( - parsedFilters.map((p) => [p.metricsViewName, p]), - ); - - // Used for sorting - const fullFilterString = parsedFilters - .map((p) => p.urlFormat) - .join(" AND "); - - const merged = { - dimensionFilters: new Map(), - measureFilters: new Map(), - complexFilters: [], - hasFilters: false, - hasClearableFilters: false, - }; - - const metricsViewCombinationToDimensionMap = get( - this.uniqueMetricsViewGroupingToDimensionArrayStore, - ); - - allMeasures.forEach((measureMap, key) => { - const filters: MeasureFilterItem[] = []; - - const pinned = pinnedFilters.has(key); - const temporary = temporaryFilterKeys.has(key); - const required = requiredFilters.has(key); - - const metricsViewNames = measureMap ? Array.from(measureMap.keys()) : []; - const measureSpecs = Array.from(measureMap?.values() || []); - - measureMap.forEach((measure, metricsViewName) => { - const parsed = parsedMap.get(metricsViewName); - if (!parsed) return; - - const [metricsViewGroup, measureName] = key.split(NAME_SEPARATOR); - - const measureFilter = parsed.measureFilters.get(measure.name as string); - if (!measureFilter) { - if (pinned || temporary || required) { - filters.push({ - dimensionName: "", - dimensions: - metricsViewCombinationToDimensionMap.get(metricsViewGroup), - name: measureName, - label: measureSpecs[0].displayName ?? "", - pinned: pinned, - required: required, - measures: measureMap, - metricsViewNames: metricsViewNames, - }); - } - } else { - if (pinned) { - measureFilter.pinned = true; - } else { - measureFilter.pinned = false; - } - measureFilter.required = required; - filters.push({ - ...measureFilter, - dimensions: - metricsViewCombinationToDimensionMap.get(metricsViewGroup), - }); - } - }); - - if (filters.length === 0) return; - - const item: MeasureFilterItem = { - ...filters[0], - measures: measureMap, - }; - item.missingRequired = required && isMeasureItemEmpty(item); - merged.measureFilters.set(key, item); - }); - - // can improve efficiency at a later date - bgh - // iterate through all the unique dimension keys - allDimensions.forEach((dimensionMap, key) => { - const filters: DimensionFilterItem[] = []; - - const firstDimension = Array.from(dimensionMap.values())[0]; - - const pinned = pinnedFilters.has(key); - const temporary = temporaryFilterKeys.has(key); - const required = requiredFilters.has(key); - - // iterate through the merged dimensions under this unique key - dimensionMap.forEach((dimension, metricsViewName) => { - const parsed = parsedMap.get(metricsViewName); - - if (!parsed) return; - - const dimFilter = parsed.dimensionFilters.get(dimension.name as string); - - if (!dimFilter) { - if (pinned || temporary || required) { - const tempData: DimensionFilterItem = { - name: firstDimension.name || "", - label: getDimensionDisplayName(firstDimension), - mode: DimensionFilterMode.Select, - selectedValues: [], - dimensions: dimensionMap, - isInclude: true, - inputText: undefined, - pinned: pinned, - required: required, - }; - - filters.push(tempData); - } - } else { - if (pinned) { - dimFilter.pinned = true; - } else { - dimFilter.pinned = false; - } - dimFilter.required = required; - filters.push(dimFilter); - } - }); - - if (filters.length === 0) return; - if ( - filters.every( - (f) => - f.isInclude === filters[0].isInclude && f.mode === filters[0].mode, - ) - ) { - const item: DimensionFilterItem = { - ...filters[0], - dimensions: dimensionMap, - }; - item.missingRequired = required && isDimensionItemEmpty(item); - merged.dimensionFilters.set(key, item); - } else { - // mixed filters - need to resolve - } - }); - - merged.hasClearableFilters = parsedFilters.some( - (p) => p.dimensionFilters.size > 0 || p.measureFilters.size > 0, - ); - - merged.hasFilters = - merged.hasClearableFilters || - pinnedFilters.size > 0 || - temporaryFilterKeys.size > 0; - - // Sorting to ensure that pills don't jump around unnecessarily in the UI - // Can be optimized - bgh - const stickyKeys = Array.from( - new Set([...requiredFilters, ...pinnedFilters]), - ); - const sortedDimensionMap = new Map( - Array.from(merged.dimensionFilters.entries()).sort((a, b) => { - return sortMeasuresOrDimensions( - a[0], - b[0], - stickyKeys, - Array.from(temporaryFilterKeys.keys()), - fullFilterString, - ); - }), - ); - - const sortedMeasureMap = new Map( - Array.from(merged.measureFilters.entries()).sort((a, b) => { - return sortMeasuresOrDimensions( - a[0], - b[0], - stickyKeys, - Array.from(temporaryFilterKeys.keys()), - fullFilterString, - ); - }), - ); - - merged.measureFilters = sortedMeasureMap; - merged.dimensionFilters = sortedDimensionMap; - - return merged; - }; - - actions = { - applyDimensionContainsMode: async ( - dimensionName: string, - searchText: string, - metricsViewNames: string[], - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(dimensionName, metricsViewNames); - const map = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - if (!filterClass) return; - const string = filterClass.applyDimensionContainsMode( - dimensionName, - searchText, - ); - - map.set(name, string || null); - }); - await this.applyFiltersToUrl(map); - }, - removeDimensionFilter: async ( - dimensionName: string, - metricsViewNames: string[], - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(dimensionName, metricsViewNames); - this.checkPinnedFilter(dimensionName, metricsViewNames); - - const map = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - if (!filterClass) return; - const string = filterClass.removeDimensionFilter(dimensionName); - map.set(name, string || null); - }); - - await this.applyFiltersToUrl(map, true); - }, - addTemporaryFilter: (measureOrDimensionKey: string) => { - this.temporaryFilterKeysStore.update((tempFilters) => { - tempFilters.set(measureOrDimensionKey, true); - return tempFilters; - }); - - // Boolean controls whether the filter pill should open the dropdown automatically - // This removes the flag after 200ms - setTimeout(() => { - this.temporaryFilterKeysStore.update((tempFilters) => { - tempFilters.set(measureOrDimensionKey, false); - return tempFilters; - }); - }, 200); - }, - toggleDimensionFilterMode: async ( - dimensionName: string, - metricsViewNames: string[], - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(dimensionName, metricsViewNames); - - const map = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - - if (!filterClass) return; - const string = filterClass.toggleDimensionFilterMode(dimensionName); - - if (!string) return; - - map.set(name, string); - }); - - await this.applyFiltersToUrl(map); - }, - applyDimensionInListMode: async ( - dimensionName: string, - values: string[], - metricsViewNames: string[], - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(dimensionName, metricsViewNames); - const map = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - - if (!filterClass) return; - const string = filterClass.applyDimensionInListMode( - dimensionName, - values, - ); - - map.set(name, string || null); - }); - - await this.applyFiltersToUrl(map); - }, - toggleDimensionValueSelections: async ( - dimensionName: string, - dimensionValues: string[], - metricsViewNames: string[], - keepPillVisible?: boolean, - isExclusiveFilter?: boolean, - exclude?: boolean, - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(dimensionName, metricsViewNames); - - const newFilters = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - - if (!filterClass) return; - - const string = filterClass.toggleDimensionValueSelections( - dimensionName, - dimensionValues, - keepPillVisible, - isExclusiveFilter, - exclude, - ); - - newFilters.set(name, string || null); - }); - - await this.applyFiltersToUrl(newFilters); - }, - setMeasureFilter: async ( - dimensionName: string, - filter: MeasureFilterEntry, - oldDimension: string, - metricsViewNames: string[], - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(filter.measure, metricsViewNames); - - const newFilters = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - - if (!filterClass) return; - - const string = filterClass.setMeasureFilter( - dimensionName, - filter, - oldDimension, - ); - - newFilters.set(name, string || null); - }); - - await this.applyFiltersToUrl(newFilters); - }, - removeMeasureFilter: async ( - dimensionName: string, - measureName: string, - metricsViewNames: string[], - ) => { - this.onFilterChange?.(); - this.checkTemporaryFilter(measureName, metricsViewNames); - this.checkPinnedFilter(measureName, metricsViewNames); - - const newFilters = new Map(); - - metricsViewNames.forEach((name) => { - const filterClass = this.metricsViewFilters.get(name); - - if (!filterClass) return; - - const string = filterClass.removeMeasureFilter( - dimensionName, - measureName, - ); - - newFilters.set(name, string || null); - }); - - await this.applyFiltersToUrl(newFilters, true); - }, - toggleFilterPin: (name: string, metricsViewNames: string[]) => { - this.pinnedFilterKeysStore.update((pinned) => { - const key = getLookupKey(metricsViewNames, name); - const deleted = pinned.delete(key); - - if (!deleted) { - pinned.add(key); - } - - this.temporaryFilterKeysStore.update((tempFilters) => { - if (deleted) { - tempFilters.set(key, false); - } else { - tempFilters.delete(key); - } - - return tempFilters; - }); - return pinned; - }); - }, - toggleFilterRequired: (name: string, metricsViewNames: string[]) => { - const key = getLookupKey(metricsViewNames, name); - this.requiredFilterKeysStore.update((required) => { - if (required.has(key)) { - required.delete(key); - } else { - required.add(key); - } - return required; - }); - // Required pills render via either pinned or required, so clear any - // temporary-flag state so the pill doesn't accidentally collapse. - this.temporaryFilterKeysStore.update((tempFilters) => { - tempFilters.delete(key); - return tempFilters; - }); - }, - }; - - checkTemporaryFilter = ( - measureOrDimensionName: string, - metricsViewNames: string[], - ) => { - const key = getLookupKey(metricsViewNames, measureOrDimensionName); - const tempFilters = get(this.temporaryFilterKeysStore); - - const deleted = tempFilters.delete(key); - if (deleted) { - this.temporaryFilterKeysStore.set(tempFilters); - } - }; - - checkPinnedFilter = ( - measureOrDimensionName: string, - metricsViewNames: string[], - ) => { - const key = getLookupKey(metricsViewNames, measureOrDimensionName); - const pinnedFilters = get(this.pinnedFilterKeysStore); - - const deleted = pinnedFilters.delete(key); - if (deleted) { - this.pinnedFilterKeysStore.set(pinnedFilters); - } - }; - - // Unclear on what this actually should do - bgh - // Go to defaults or truly clear all filters? - clearAllFilters = async () => { - this.onFilterChange?.(); - this.temporaryFilterKeysStore.set(new Map()); - const existingParams = new URLSearchParams(window.location.search); - const filterParamsToDelete = Array.from(existingParams.keys()).filter( - (key) => key.startsWith(ExploreStateURLParams.Filters), - ); - filterParamsToDelete.forEach((key) => { - existingParams.delete(key); - }); - const string = existingParams.toString(); - if (string) { - await goto(`?${string}`); - return; - } else { - await goto(`?clear=true`); - } - }; - - applyFiltersToUrl = async ( - filters: Map, - allowFilterClear = false, - ) => { - const existingParams = new URLSearchParams(window.location.search); - - existingParams.delete("default"); - existingParams.delete("clear"); - existingParams.delete(ExploreStateURLParams.Filters); - - filters.forEach((filterString, mvName) => { - const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; - if (filterString === null) { - existingParams.delete(paramKey); - } else { - existingParams.set(paramKey, filterString); - } - }); - - const string = existingParams.toString(); - - if (!string) { - if (allowFilterClear) { - await goto(`?clear=true`); - return; - } else { - await goto(`?default=true`); - return; - } - } else { - await goto(`?${string}`); - } - }; -} - -/** - * Sorts filter items with the following priority: - * 1. Sticky items (required or pinned), following their order in stickyKeys - * 2. Regular filter items, following their appearance in the full filter string - * 3. Temporary items - */ -function sortMeasuresOrDimensions( - aKey: string, - bKey: string, - stickyKeys: string[], - temporaryFilterKeys: string[], - fullFilterString: string, -): number { - const isASticky = stickyKeys.includes(aKey); - const isBSticky = stickyKeys.includes(bKey); - const isATemporary = temporaryFilterKeys.includes(aKey); - const isBTemporary = temporaryFilterKeys.includes(bKey); - - if (isASticky && isBSticky) { - return stickyKeys.indexOf(aKey) - stickyKeys.indexOf(bKey); - } - if (isASticky !== isBSticky) { - return isASticky ? -1 : 1; - } - - if (isATemporary && isBTemporary) { - return ( - temporaryFilterKeys.indexOf(aKey) - temporaryFilterKeys.indexOf(bKey) - ); - } - if (isATemporary !== isBTemporary) { - return isATemporary ? 1 : -1; - } - - const aName = aKey.split(NAME_SEPARATOR)[1] || aKey; - const bName = bKey.split(NAME_SEPARATOR)[1] || bKey; - - const aIndex = fullFilterString.indexOf(aName); - const bIndex = fullFilterString.indexOf(bName); - - return aIndex - bIndex; -} - -function isDimensionItemEmpty(item: DimensionFilterItem): boolean { - const hasSelectedValues = (item.selectedValues?.length ?? 0) > 0; - const hasInputText = !!item.inputText && item.inputText.length > 0; - return !hasSelectedValues && !hasInputText; -} - -function isMeasureItemEmpty(item: MeasureFilterItem): boolean { - return !item.filter; -} - -// This should be deprecated eventually in favor of better support for variously formatted expressions -export function flattenExpression( - expression: V1Expression | undefined, -): V1Expression { - if (!expression) { - return createAndExpression([]); - } - - let root: V1Expression; - - // Ensure top level is an OPERATION_AND - if (!expression.cond || expression.cond.op !== V1Operation.OPERATION_AND) { - root = createAndExpression([expression]); - } else { - root = expression; - } - - const rootCond = root.cond; - if ( - !rootCond || - rootCond.op !== V1Operation.OPERATION_AND || - !Array.isArray(rootCond.exprs) - ) { - return root; - } - - // Recursively flatten all nested ANDs, preserving order - rootCond.exprs = flattenAndExprs(rootCond.exprs); - - // Normalize array-valued IN/NIN expressions into individual value expressions - rootCond.exprs = rootCond.exprs.map(flattenInExpressionValues); - - return root; -} - -function flattenAndExprs(exprs: V1Expression[]): V1Expression[] { - const result: V1Expression[] = []; - - for (const expr of exprs) { - const cond = expr.cond; - if ( - cond && - cond.op === V1Operation.OPERATION_AND && - Array.isArray(cond.exprs) - ) { - // Inline children in order - result.push(...flattenAndExprs(cond.exprs)); - } else { - result.push(expr); - } - } - - return result; -} - -// wip - bgh -function mergeFilters( - metricsViewItems: Map>, - locations: Map, - mergeStrategy: "all" = "all", -): UniqueLookup { - const merged = new Map>(); - - if (mergeStrategy === "all") { - locations.forEach((mvNames, name) => { - const key = getLookupKey(mvNames, name); - const dimMap = new Map(); - mvNames.forEach((mvName) => { - const dim = metricsViewItems.get(mvName)?.get(name); - if (dim) { - dimMap.set(mvName, dim); - } - }); - merged.set(key, dimMap); - }); - } - - return merged; -} - -class StoreOfStores { - private store: Writable>; - subscribe: Readable>["subscribe"]; - - constructor() { - this.store = writable>(new Map()); - this.subscribe = this.store.subscribe; - } - - forEach = ( - callback: (value: T, key: string, map: Map) => void, - ) => { - const map = get(this.store); - map.forEach(callback); - }; - - get(key: string): T | undefined { - const map = get(this.store); - return map.get(key); - } - - set(key: string, value: T) { - this.store.update((map) => { - const next = new Map(map); - next.set(key, value); - return next; - }); - } -} - -function getAllCombinations(items: string[]): string[][] { - const results: string[][] = []; - const n = items.length; - - function dfs(start: number, path: string[]) { - if (path.length > 0) { - results.push(structuredClone(path)); - } - - for (let i = start; i < n; i++) { - path.push(items[i]); - dfs(i + 1, path); - path.pop(); - } - } - - dfs(0, []); - - return results; -} - -function createDimensionsForUniqueCombinationsOfMetricsViews( - metricsViewMap: Map< - MetricsViewName, - Map - >, -) { - const metricsViewNames = Array.from(metricsViewMap.keys()).sort(); - - const allSortedCombinationsOfMetricsViewNames: string[][] = - getAllCombinations(metricsViewNames); - - const map = new Map(); - - allSortedCombinationsOfMetricsViewNames.forEach((group) => { - const dimensions: Map = new Map(); - const key = group.join(MV_NAME_SEPARATOR); - - group.forEach((mvName) => { - const dims = metricsViewMap.get(mvName); - - if (dims) { - dims.forEach((dim, dimName) => { - dimensions.set(dimName, dim); - }); - } - }); - - map.set(key, Array.from(dimensions.values())); - }); - - return map; -} - -function updateNameToMetricsViews( - specs: Spec[] | undefined, - getName: (spec: Spec) => Name | undefined, - nameToMetricsViewNames: Map, - nameToSpecForView: Map, - metricsViewName: MetricsViewName, -) { - specs?.forEach((spec) => { - const name = getName(spec); - if (!name) return; - - const existing = nameToMetricsViewNames.get(name) ?? []; - nameToMetricsViewNames.set(name, [...existing, metricsViewName]); - nameToSpecForView.set(name, spec); - }); -} diff --git a/web-common/src/features/canvas/stores/filter-state.ts b/web-common/src/features/canvas/stores/filter-state.ts deleted file mode 100644 index 23565eebe7f2..000000000000 --- a/web-common/src/features/canvas/stores/filter-state.ts +++ /dev/null @@ -1,649 +0,0 @@ -import { getFiltersFromText } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/dimension-search-text-utils"; -import type { MeasureFilterEntry } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; -import { - mergeDimensionAndMeasureFilters, - splitWhereFilter, -} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; -import { toggleDimensionFilterValue } from "@rilldata/web-common/features/dashboards/state-managers/actions/dimension-filters.ts"; -import { - createAndExpression, - createInExpression, - createLikeExpression, - forEachIdentifier, - getValuesInExpression, - isExpressionUnsupported, - negateExpression, - sanitiseExpression, -} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import type { - MetricsViewSpecDimension, - V1Expression, -} from "@rilldata/web-common/runtime-client"; -import { - type MetricsViewSpecMeasure, - V1Operation, -} from "@rilldata/web-common/runtime-client"; -import { get, writable, type Writable } from "svelte/store"; -import type { DimensionFilterItem } from "../../dashboards/state-managers/selectors/dimension-filters"; -import { DimensionFilterMode } from "../../dashboards/filters/dimension-filters/constants"; -import type { MeasureFilterItem } from "../../dashboards/state-managers/selectors/measure-filters"; -import type { DimensionThresholdFilter } from "../../dashboards/stores/explore-state"; -import { convertExpressionToFilterParam } from "../../dashboards/url-state/filters/converters"; -import { FilterManager, type UIFilters } from "./filter-manager"; -import { getDimensionDisplayName } from "../../dashboards/filters/getDisplayName"; -import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; - -export type ParsedFilters = ReturnType; - -export function initFilterBase(metricsViewName: string) { - return { - where: createAndExpression([]), - dimensionFilter: createAndExpression([]), - urlFormat: undefined as string | undefined, - metricsViewName, - dimensionsWithInListFilter: [], - dimensionThresholdFilters: [], - measureFilters: new Map(), - dimensionFilters: new Map(), - complexFilters: [] as V1Expression[], - hasFilters: false, - hasClearableFilters: false, - }; -} - -// Exists at the global and widget level on Canvas -export class FilterState { - parsed: Writable; - parsedDefaultFilters: Writable; - temporaryFilterKeys = writable(new Set()); - - constructor( - private metricsViewName: string, - private manager: FilterManager, - public instanceId: string, - ) { - this.parsed = writable(initFilterBase(this.metricsViewName)); - this.parsedDefaultFilters = writable(initFilterBase(this.metricsViewName)); - } - - setTemporaryFilterName = (key: string) => { - const keys = get(this.temporaryFilterKeys); - keys.add(key); - this.temporaryFilterKeys.set(keys); - - this.reprocessExistingFilters(); - }; - - reprocessExistingFilters = () => { - const parsed = get(this.parsed); - - this.parsed.set( - this.parseFilter({ - expr: parsed.where, - dimensionsWithInListFilter: parsed.dimensionsWithInListFilter, - }), - ); - }; - - onFilterStringChange(filterString: string) { - const { urlFormat } = get(this.parsed); - if (urlFormat === filterString) return; - - this.parsed.set(this.parseFilterString(filterString)); - } - - onDefaultExpressionChange = (expr: V1Expression | undefined) => { - expr = expr ?? createAndExpression([]); - - this.parsedDefaultFilters.set( - this.parseFilter({ - expr, - }), - ); - }; - - clearAllFilters = () => { - this.parsed.set(this.parseFilterString("")); - this.temporaryFilterKeys.set(new Set()); - return ""; - }; - - parseFilter({ - expr, - filterString, - dimensionsWithInListFilter, - }: { - expr: V1Expression; - filterString?: string; - dimensionsWithInListFilter?: string[]; - }): ParsedFilters { - const where = structuredClone(expr); - const { dimensionThresholdFilters, dimensionFilters } = - splitWhereFilter(expr); - - const isComplexFilter = false; - - filterString = - filterString || - getFilterParam( - expr, - dimensionThresholdFilters, - dimensionsWithInListFilter ?? [], - ) || - ""; - - dimensionsWithInListFilter = - dimensionsWithInListFilter ?? - getFiltersFromText(filterString).dimensionsWithInlistFilter; - - if (isComplexFilter) { - return { - urlFormat: filterString, - where: where, - dimensionFilter: dimensionFilters, - metricsViewName: this.metricsViewName, - dimensionsWithInListFilter, - dimensionThresholdFilters, - dimensionFilters: new Map(), - measureFilters: new Map(), - complexFilters: [expr], - hasClearableFilters: false, - hasFilters: false, - }; - } - - const dimensionMap = - get(this.manager.dimensionsForMetricsView).get(this.metricsViewName) ?? - new Map(); - const measureMap = - get(this.manager.measuresForMetricsView).get(this.metricsViewName) ?? - new Map(); - - const processed = processExpression({ - expr: dimensionFilters, - measureMap, - dimensionMap, - metricsViewName: this.metricsViewName, - dimensionsWithInListFilter, - dimensionThresholdFilters, - temporaryFilterKeys: get(this.temporaryFilterKeys), - }); - - return { - urlFormat: filterString, - where: where, - dimensionFilter: dimensionFilters, - metricsViewName: this.metricsViewName, - dimensionsWithInListFilter, - dimensionThresholdFilters, - ...processed, - complexFilters: [], - }; - } - - parseFilterString = (filterString: string | undefined) => { - const { expr, dimensionsWithInlistFilter: dimensionsWithInListFilter } = - getFiltersFromText(filterString ?? ""); - - return this.parseFilter({ expr, filterString, dimensionsWithInListFilter }); - }; - - removeDimensionFilter = (dimensionName: string) => { - const { - dimensionFilter: df, - dimensionThresholdFilters, - dimensionsWithInListFilter, - } = get(this.parsed); - const exprIdx = df.cond?.exprs?.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ); - if (!(exprIdx === undefined || exprIdx === -1)) { - df.cond?.exprs?.splice(exprIdx, 1); - } - - return getFilterParam( - df, - dimensionThresholdFilters, - dimensionsWithInListFilter, - ); - }; - - applyDimensionContainsMode = (dimensionName: string, searchText: string) => { - const { - dimensionFilter: wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - } = get(this.parsed); - - const exprIndex = wf.cond?.exprs?.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ); - - if (exprIndex === undefined || exprIndex === -1) { - wf.cond!.exprs!.push( - createLikeExpression(dimensionName, `%${searchText}%`, false), - ); - } else { - const operation = wf.cond!.exprs![exprIndex].cond!.op; - const isExclude = - operation === V1Operation.OPERATION_NLIKE || - operation === V1Operation.OPERATION_NIN; - wf.cond!.exprs![exprIndex] = createLikeExpression( - dimensionName, - `%${searchText}%`, - isExclude, - ); - } - return getFilterParam( - wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - ); - }; - - toggleDimensionFilterMode = (dimensionName: string) => { - const { - dimensionsWithInListFilter, - dimensionFilter: wf, - dimensionThresholdFilters, - } = get(this.parsed); - - if (!wf.cond?.exprs) return; - const exprIdx = wf.cond.exprs.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ); - if (exprIdx === -1) return; - wf.cond.exprs[exprIdx] = negateExpression(wf.cond.exprs[exprIdx]); - - return getFilterParam( - wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - ); - }; - - toggleDimensionValueSelections = ( - dimensionName: string, - dimensionValues: (string | null)[], - keepPillVisible?: boolean, - isExclusiveFilter?: boolean, - exclude: boolean = false, - ) => { - const { - dimensionFilter: wf, - dimensionsWithInListFilter, - dimensionThresholdFilters, - } = get(this.parsed); - - let exprIndex = - wf.cond?.exprs?.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ) ?? -1; - let expr = wf.cond?.exprs?.[exprIndex]; - - if (!expr?.cond?.exprs) { - expr = createInExpression(dimensionName, dimensionValues, exclude); - wf.cond?.exprs?.push(expr); - exprIndex = wf.cond!.exprs!.length - 1; - } else { - const wasLikeFilter = - expr?.cond?.op === V1Operation.OPERATION_LIKE || - expr?.cond?.op === V1Operation.OPERATION_NLIKE; - - const wasInListFilter = - dimensionsWithInListFilter.includes(dimensionName); - - if (wasLikeFilter || wasInListFilter) { - eventBus.emit("notification", { - message: "Converted filter type to Select", - link: { - text: "Undo", - href: window.location.href, - }, - }); - - if (wasInListFilter) { - const idx = dimensionsWithInListFilter.indexOf(dimensionName); - if (idx !== -1) { - dimensionsWithInListFilter.splice(idx, 1); - } - } - - if (dimensionValues.length) { - expr = createInExpression(dimensionName, dimensionValues, exclude); - wf.cond?.exprs?.splice(exprIndex, 1, expr); - } - } else if (expr) { - dimensionValues.forEach((dimensionValue) => { - toggleDimensionFilterValue( - expr!, - dimensionValue, - !!isExclusiveFilter, - ); - }); - - if (expr?.cond?.exprs?.length === 1) { - wf.cond?.exprs?.splice(exprIndex, 1); - } - } - } - - return getFilterParam( - wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - ); - }; - - /** - * Adds dimension values to the filter without removing existing ones. - * Unlike toggleDimensionValueSelections, this never removes a value - * that is already present; it only appends new values. - */ - addDimensionValueSelections = ( - dimensionName: string, - dimensionValues: (string | null)[], - ) => { - const { - dimensionFilter: wf, - dimensionsWithInListFilter, - dimensionThresholdFilters, - } = get(this.parsed); - - const exprIndex = - wf.cond?.exprs?.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ) ?? -1; - const expr = wf.cond?.exprs?.[exprIndex]; - - if (!expr?.cond?.exprs) { - // No existing filter for this dimension; create one - const newExpr = createInExpression(dimensionName, dimensionValues); - wf.cond?.exprs?.push(newExpr); - } else { - // Append only values not already in the filter - const existing = getValuesInExpression(expr); - const existingSet = new Set(existing); - for (const val of dimensionValues) { - if (!existingSet.has(val)) { - existing.push(val); - } - } - const ident = expr.cond.exprs[0]; - expr.cond.exprs = [ident, ...existing.map((v) => ({ val: v }))]; - } - - return getFilterParam( - wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - ); - }; - - applyDimensionInListMode = (dimensionName: string, values: string[]) => { - const { - dimensionFilter: wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - } = get(this.parsed); - const isExclude = false; - - const expr = createInExpression(dimensionName, values, isExclude); - - dimensionsWithInListFilter.push(dimensionName); - - const exprIndex = - wf.cond?.exprs?.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ) ?? -1; - if (exprIndex === undefined || exprIndex === -1) { - wf.cond!.exprs!.push(expr); - } else { - wf.cond!.exprs![exprIndex] = expr; - } - - return getFilterParam( - wf, - dimensionThresholdFilters, - dimensionsWithInListFilter, - ); - }; - - setMeasureFilter = ( - dimensionName: string, - filter: MeasureFilterEntry, - oldDimension: string, - ) => { - const { - dimensionThresholdFilters: dtfs, - dimensionsWithInListFilter, - dimensionFilter, - } = get(this.parsed); - - const dimIdx = dtfs.findIndex( - (dtf) => dtf.name === (oldDimension || dimensionName), - ); - let dimThresholdFilter = dtfs[dimIdx]; - - if (!dimThresholdFilter) { - dimThresholdFilter = { name: dimensionName, filters: [] }; - dtfs.push(dimThresholdFilter); - } else { - if (oldDimension && oldDimension !== dimensionName) { - dimThresholdFilter.name = dimensionName; - } - const filters = dimThresholdFilter.filters; - const exprIdx = filters.findIndex((f) => f.measure === filter.measure); - if (exprIdx !== -1) { - filters.splice(exprIdx, 1); - } - } - - const exprIdx = dimThresholdFilter.filters.findIndex( - (f) => f.measure === filter.measure, - ); - if (exprIdx === -1) { - dimThresholdFilter.filters.push(filter); - } else { - dimThresholdFilter.filters.splice(exprIdx, 1, filter); - } - - return getFilterParam(dimensionFilter, dtfs, dimensionsWithInListFilter); - }; - removeMeasureFilter = (dimensionName: string, measureName: string) => { - const { - dimensionThresholdFilters: dtfs, - dimensionsWithInListFilter, - dimensionFilter, - } = get(this.parsed); - - const dimIdx = dtfs.findIndex((dtf) => dtf.name === dimensionName); - const dimThresholdFilter = dtfs[dimIdx]; - - if (dimThresholdFilter) { - const filters = dimThresholdFilter.filters; - const exprIdx = filters.findIndex((f) => f.measure === measureName); - if (exprIdx !== -1) { - filters.splice(exprIdx, 1); - } - if (filters.length === 0) { - dtfs.splice(dimIdx, 1); - } - } - - return getFilterParam(dimensionFilter, dtfs, dimensionsWithInListFilter); - }; -} - -function processExpression({ - expr, - dimensionMap, - measureMap, - metricsViewName, - dimensionsWithInListFilter, - dimensionThresholdFilters, - temporaryFilterKeys, -}: { - expr: V1Expression; - measureMap: Map; - dimensionMap: Map; - metricsViewName: string; - dimensionsWithInListFilter: string[]; - dimensionThresholdFilters: DimensionThresholdFilter[]; - temporaryFilterKeys: Set; -}): UIFilters { - const isComplex = isExpressionUnsupported(expr); - const dimensionFilters = getDimensionFilterItemsMap( - dimensionMap, - expr, - dimensionsWithInListFilter, - metricsViewName, - ); - const measureFilters = getCanvasMeasureFiltersMap( - measureMap, - dimensionThresholdFilters, - ); - - const allDimensions = Array.from(dimensionMap.values()); - temporaryFilterKeys.forEach((key) => { - if (dimensionFilters.has(key)) { - temporaryFilterKeys.delete(key); - return; - } - const dimension = dimensionMap.get(key); - if (dimension) { - dimensionFilters.set(key, { - name: key, - label: getDimensionDisplayName(dimension), - dimensions: new Map([[metricsViewName, dimension]]), - selectedValues: [], - mode: DimensionFilterMode.Select, - isInclude: true, - pinned: false, - }); - } - - const measure = measureMap.get(key); - if (measureFilters.has(key)) { - temporaryFilterKeys.delete(key); - return; - } - if (measure) { - measureFilters.set(key, { - dimensionName: "", - dimensions: allDimensions, - name: key, - label: measure.displayName ?? "", - pinned: false, - measures: new Map([[metricsViewName, measure]]), - metricsViewNames: [metricsViewName], - }); - } - }); - return { - complexFilters: isComplex ? [expr] : [], - measureFilters: measureFilters, - dimensionFilters: dimensionFilters, - hasFilters: dimensionFilters.size > 0 || measureFilters.size > 0, - hasClearableFilters: dimensionFilters.size > 0 || measureFilters.size > 0, - }; -} - -export function getCanvasMeasureFiltersMap( - measureIdMap: Map, - dimensionThresholdFilters: DimensionThresholdFilter[], -): Map { - const map = new Map(); - - dimensionThresholdFilters.forEach((dtf) => { - const filter = dtf.filters[0]; - const measureName = filter.measure; - const measure = measureIdMap.get(measureName); - if (!measure) return; - - const entry: MeasureFilterItem = { - dimensionName: dtf.name, - name: measureName, - label: measure.displayName || measure.expression || filter.measure, - filter: filter, - }; - - map.set(measureName, entry); - }); - - return map; -} - -export function getDimensionFilterItemsMap( - dimensionIdMap: Map, - filter: V1Expression | undefined, - dimensionsWithInlistFilter: string[], - metricsViewName: string, -): Map { - if (!filter) return new Map(); - const filteredDimensions: Map = new Map(); - const addedDimension = new Set(); - - forEachIdentifier(filter, (e, ident) => { - if (addedDimension.has(ident) || !dimensionIdMap.has(ident)) return; - const dim = dimensionIdMap.get(ident); - - if (!dim) { - return; - } - addedDimension.add(ident); - - const op = e.cond?.op; - if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { - const isInListMode = dimensionsWithInlistFilter.includes(ident); - filteredDimensions.set(ident, { - name: ident, - label: getDimensionDisplayName(dim), - dimensions: new Map([[metricsViewName, dim]]), - mode: isInListMode - ? DimensionFilterMode.InList - : DimensionFilterMode.Select, - selectedValues: getValuesInExpression(e), - isInclude: e.cond?.op === V1Operation.OPERATION_IN, - inputText: undefined, - pinned: false, - }); - } else if ( - op === V1Operation.OPERATION_LIKE || - op === V1Operation.OPERATION_NLIKE - ) { - filteredDimensions.set(ident, { - name: ident, - label: getDimensionDisplayName(dim), - mode: DimensionFilterMode.Contains, - selectedValues: [], - inputText: e.cond?.exprs?.[1]?.val?.toString?.() ?? "", - isInclude: e.cond?.op === V1Operation.OPERATION_LIKE, - dimensions: new Map([[metricsViewName, dim]]), - pinned: false, - }); - } - }); - - return filteredDimensions; -} - -export function getFilterParam( - whereFilter: V1Expression, - dtf: DimensionThresholdFilter[], - dimensionsWithInlistFilter: string[], -) { - const mergedFilters = - sanitiseExpression( - mergeDimensionAndMeasureFilters( - whereFilter ?? createAndExpression([]), - dtf, - ), - undefined, - ) ?? createAndExpression([]); - - return convertExpressionToFilterParam( - mergedFilters, - dimensionsWithInlistFilter, - ); -} diff --git a/web-common/src/features/dashboards/filters/CanvasFilterChipsReadOnly.svelte b/web-common/src/features/dashboards/filters/CanvasFilterChipsReadOnly.svelte deleted file mode 100644 index 94f2be24a5c9..000000000000 --- a/web-common/src/features/dashboards/filters/CanvasFilterChipsReadOnly.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
-
- {#if timeRangeString} - - {/if} -
- - {#each dimensionFilters as [id, filterData] (id)} - {@const metricsViewNames = Array.from(filterData.dimensions.keys())} - {@const dimension = filterData.dimensions.get(metricsViewNames[0])} - - {#if dimension && dimension.name} - - {/if} - {/each} - - {#each measureFilters as [id, filterData] (id)} - {@const metricsViewNames = Array.from(filterData?.measures?.keys() ?? [])} - {@const measure = filterData.measures?.get(metricsViewNames[0])} - - {#if measure && measure.name} - - {/if} - {/each} -
diff --git a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts index fb1c88e02ddd..b66a22b2a321 100644 --- a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -16,7 +16,11 @@ export class YAMLConfigProvider { public cleanup: (() => void) | undefined = undefined; - public constructor(public readonly editable: boolean = false) {} + public constructor(public editable: boolean = false) {} + + public setEditable(newEditable: boolean) { + this.editable = newEditable; + } public togglePinnedFilter(filter: string) { this.pinnedFilters[filter] = !this.pinnedFilters[filter]; diff --git a/web-common/src/features/dashboards/stores/filter-utils.ts b/web-common/src/features/dashboards/stores/filter-utils.ts index a2595c2cb785..9948f10d4e6d 100644 --- a/web-common/src/features/dashboards/stores/filter-utils.ts +++ b/web-common/src/features/dashboards/stores/filter-utils.ts @@ -1,11 +1,11 @@ import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import type { DimensionThresholdFilter } from "@rilldata/web-common/features/dashboards/stores/explore-state"; import { - V1Operation, type MetricsViewSpecDimension, type MetricsViewSpecMeasure, type V1Condition, type V1Expression, + V1Operation, type V1Subquery, } from "@rilldata/web-common/runtime-client"; @@ -538,3 +538,58 @@ export function flattenInExpressionValues(expr: V1Expression) { expr.cond!.op === V1Operation.OPERATION_NIN, ); } + +// This should be deprecated eventually in favor of better support for variously formatted expressions +export function flattenExpression( + expression: V1Expression | undefined, +): V1Expression { + if (!expression) { + return createAndExpression([]); + } + + let root: V1Expression; + + // Ensure top level is an OPERATION_AND + if (!expression.cond || expression.cond.op !== V1Operation.OPERATION_AND) { + root = createAndExpression([expression]); + } else { + root = expression; + } + + const rootCond = root.cond; + if ( + !rootCond || + rootCond.op !== V1Operation.OPERATION_AND || + !Array.isArray(rootCond.exprs) + ) { + return root; + } + + // Recursively flatten all nested ANDs, preserving order + rootCond.exprs = flattenAndExprs(rootCond.exprs); + + // Normalize array-valued IN/NIN expressions into individual value expressions + rootCond.exprs = rootCond.exprs.map(flattenInExpressionValues); + + return root; +} + +function flattenAndExprs(exprs: V1Expression[]): V1Expression[] { + const result: V1Expression[] = []; + + for (const expr of exprs) { + const cond = expr.cond; + if ( + cond && + cond.op === V1Operation.OPERATION_AND && + Array.isArray(cond.exprs) + ) { + // Inline children in order + result.push(...flattenAndExprs(cond.exprs)); + } else { + result.push(expr); + } + } + + return result; +} diff --git a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte index 6f9ff41f726f..209186a0bc6d 100644 --- a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte +++ b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte @@ -41,8 +41,12 @@ export let enhance; export let metricsViewName: string; export let exploreName: string; - export let filters: ExpressionFilterManager; - export let timeControls: TimeControls; + 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: ExpressionFilterManager | undefined = undefined; + export let timeControls: TimeControls | undefined = undefined; const RUN_AS_OPTIONS = [ { From 1c51894c6398efc97109da00722826a2faee83d0 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Thu, 6 Aug 2026 17:34:13 +0530 Subject: [PATCH 15/21] Update leaderboard/dimension display --- .../canvas/CanvasDashboardWrapper.svelte | 4 +-- .../canvas/filters/CanvasFilters.svelte | 2 -- .../dimension-table/DimensionDisplay.svelte | 25 ++++--------------- .../dimension-table/DimensionTable.svelte | 2 +- .../dimension-table/dimension-table-utils.ts | 5 ++-- .../filters/ExpressionFilterURLSync.svelte.ts | 16 +++++------- .../dashboards/filters/Filters.svelte | 19 -------------- .../filters/VerticalExpressionFilters.svelte | 2 +- .../DimensionFilterManager.svelte.ts | 2 +- .../src/features/dashboards/filters/utils.ts | 2 +- .../dashboards/leaderboard/Leaderboard.svelte | 15 +++-------- .../leaderboard/LeaderboardDisplay.svelte | 6 ++--- .../src/features/dashboards/selectors.ts | 17 +++++++------ .../loaders/DashboardStateSync.ts | 23 +++++++++++------ .../selectors/dashboard-queries.ts | 8 +++--- .../dashboards/stores/dashboard-stores.ts | 13 ++++++++++ .../dashboards/stores/filter-utils.ts | 3 ++- .../dashboards/workspace/Dashboard.svelte | 17 +++++++------ .../test/RuntimeContextHarness.svelte | 2 +- 19 files changed, 80 insertions(+), 103 deletions(-) diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index 44dff951e101..dbbb05dc9551 100644 --- a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte +++ b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte @@ -27,7 +27,7 @@ children: Snippet; canvasName: string; maxWidth: number; - clientWidth: number; + clientWidth?: number; showGrabCursor?: boolean; filtersEnabled?: boolean | undefined; embedded?: boolean; @@ -74,7 +74,7 @@ if (e.target === e.currentTarget) onClick(); }} > - + {/if} diff --git a/web-common/src/features/canvas/filters/CanvasFilters.svelte b/web-common/src/features/canvas/filters/CanvasFilters.svelte index 7fe2b4897c9e..9dd538154a78 100644 --- a/web-common/src/features/canvas/filters/CanvasFilters.svelte +++ b/web-common/src/features/canvas/filters/CanvasFilters.svelte @@ -11,12 +11,10 @@ let { maxWidth, - builder = false, canvasName, }: { readOnly?: boolean; maxWidth: number; - builder?: boolean; canvasName: string; } = $props(); diff --git a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte index 4880eee5583f..3f934de495fc 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte @@ -22,13 +22,10 @@ getComparisonRequestMeasures, getURIRequestMeasure, } from "../dashboard-utils"; - import { mergeDimensionAndMeasureFilters } from "../filters/measure-filters/measure-filter-utils"; import { getSort } from "../leaderboard/leaderboard-utils"; import { getFiltersForOtherDimensions } from "../selectors"; import { getMeasuresForDimensionOrLeaderboardDisplay } from "../state-managers/selectors/dashboard-queries"; import { dimensionSearchText } from "../stores/dashboard-stores"; - import { sanitiseExpression } from "../stores/filter-utils"; - import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state"; import DimensionHeader from "./DimensionHeader.svelte"; import DimensionTable from "./DimensionTable.svelte"; import { getDimensionFilterWithSearch } from "./dimension-table-utils"; @@ -37,9 +34,8 @@ export let timeRange: V1TimeRange; export let comparisonTimeRange: V1TimeRange | undefined; - export let whereFilter: V1Expression; + export let whereFilter: V1Expression | undefined; export let metricsViewName: string; - export let dimensionThresholdFilters: DimensionThresholdFilter[]; export let visibleMeasureNames: string[]; export let timeControlsReady: boolean; export let dimension: MetricsViewSpecDimension; @@ -68,7 +64,7 @@ $: selectedValues = selectedDimensionValues( client, [metricsViewName], - $dashboardStore.whereFilter, + whereFilter, dimensionName, timeRange.start, timeRange.end, @@ -85,7 +81,7 @@ $leaderboardShowContextForAllMeasures ? null : $leaderboardSortByMeasureName, - dimensionThresholdFilters, + whereFilter, visibleMeasureNames, ).map((name) => ({ name }) as V1MetricsViewAggregationMeasure), @@ -118,13 +114,7 @@ measures: filteredMeasures.filter( (m) => !m.comparisonValue && !m.comparisonDelta && !m.comparisonRatio, ), - where: sanitiseExpression( - mergeDimensionAndMeasureFilters( - getFiltersForOtherDimensions(whereFilter, dimensionName), - dimensionThresholdFilters, - ), - undefined, - ), + where: getFiltersForOtherDimensions(whereFilter, dimensionName), timeRange, }, { @@ -155,11 +145,6 @@ !!comparisonTimeRange, ); - $: where = sanitiseExpression( - mergeDimensionAndMeasureFilters(filterSet, dimensionThresholdFilters), - undefined, - ); - $: sortedQuery = createQueryServiceMetricsViewAggregation( client, { @@ -169,7 +154,7 @@ timeRange, comparisonTimeRange, sort, - where, + where: whereFilter, limit: queryLimit.toString(), offset: "0", }, diff --git a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte index 6c4c7667e3e0..5fc08a2e2664 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte @@ -143,7 +143,7 @@ TableCells – the cell contents. estimateColumnSize = estimateColumnSizes( columns, columnWidths, - containerWidth, + rows, config, ); diff --git a/web-common/src/features/dashboards/dimension-table/dimension-table-utils.ts b/web-common/src/features/dashboards/dimension-table/dimension-table-utils.ts index 92239964a936..9155b30e6130 100644 --- a/web-common/src/features/dashboards/dimension-table/dimension-table-utils.ts +++ b/web-common/src/features/dashboards/dimension-table/dimension-table-utils.ts @@ -77,10 +77,12 @@ export function updateFilterOnSearch( } export function getDimensionFilterWithSearch( - filters: V1Expression, + filters: V1Expression | undefined, searchText: string, dimensionName: string, ) { + if (!filters) return undefined; + const filterForDimension = getFiltersForOtherDimensions(filters, dimensionName) ?? createAndExpression([]); @@ -190,7 +192,6 @@ export function estimateColumnSizes( [key: string]: number; }, rows: DimensionTableRow[], - containerWidth: number, config: DimensionTableConfig, ): number[] { const estimatedColumnSizes = columns.map((column, i) => { diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts index fb0655ebc426..c4c0d1c6927e 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts @@ -5,12 +5,11 @@ import { convertExpressionToFilterParam } from "@rilldata/web-common/features/da import { goto } from "$app/navigation"; export class ExpressionFilterURLSync { - private prevUrlSearch = ""; - public constructor(expressionFilterManager: ExpressionFilterManager) { $effect(() => expressionFilterManager.setUrlParams(page.url.searchParams)); + $effect(() => { - const newUrlSearchParams = new URLSearchParams(page.url.searchParams); + const newUrl = new URL(page.url); expressionFilterManager.metricsViewsProvider.metricsViewNames.forEach( (mvName) => { @@ -20,18 +19,15 @@ export class ExpressionFilterURLSync { expressionFilterManager.exprByMetricsView[mvName], expressionFilterManager.inList, ); - newUrlSearchParams.set(key, param); + newUrl.searchParams.set(key, param); } else { - newUrlSearchParams.delete(key); + newUrl.searchParams.delete(key); } }, ); - const newUrlSearch = newUrlSearchParams.toString(); - if (newUrlSearch === this.prevUrlSearch) return; - - this.prevUrlSearch = newUrlSearch; - void goto(`?${newUrlSearch}`); + if (newUrl.search === page.url.search) return; + void goto(newUrl); }); } } diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 386f8042112b..10d2dabeecab 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -40,8 +40,6 @@ import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import ExpressionFilters from "./ExpressionFilters.svelte"; - import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; - import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; const { rillTime } = featureFlags; @@ -59,9 +57,6 @@ const { exploreName, validSpecStore, - actions: { - filters: { setFilter }, - }, selectors: { dimensions: { timeDimensions }, pivot: { showPivot }, @@ -113,20 +108,6 @@ let exploreState = $derived(useExploreState($exploreName)); let activeTimeZone = $derived($exploreState?.selectedTimezone); - // There seems to be an infinite loop here. Checking synced param should be temporary. - // TODO: fix it by making sure we do not trigger effect. - let prevSyncedUrlParam = ""; - $effect(() => { - const expr = expressionFilterManager.exprByMetricsView[metricsViewName]; - const inList = expressionFilterManager.inList; - - const urlParam = convertExpressionToFilterParam(expr, inList); - if (urlParam === prevSyncedUrlParam) return; - - prevSyncedUrlParam = urlParam; - setFilter(expr, inList); - }); - let selectedRangeAlias = $derived( selectedTimeRange?.name === TimeRangePreset.CUSTOM ? `${selectedTimeRange.start.toISOString()},${selectedTimeRange.end.toISOString()}` diff --git a/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte b/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte index 6c53730ef060..4e116a7090cb 100644 --- a/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte @@ -73,7 +73,7 @@
{#if hasFilters} {/if}
diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts index f7752458ca4c..113cd2faecda 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts @@ -162,7 +162,7 @@ export class DimensionFilterManager { notify ) { eventBus.emit("notification", { - message: "Converted filter type to Select", + message: m.filter_converted_to_select(), link: { text: m.common_undo(), href: page.url.href, diff --git a/web-common/src/features/dashboards/filters/utils.ts b/web-common/src/features/dashboards/filters/utils.ts index 6adb5a779026..98b43b292e3f 100644 --- a/web-common/src/features/dashboards/filters/utils.ts +++ b/web-common/src/features/dashboards/filters/utils.ts @@ -7,7 +7,7 @@ export function getMissingRequiredFilters( ) { return Object.keys(yamlConfigProvider.requiredFilters).filter( (filterName) => { - return !expressionFilterManager.filterManagersMap[filterName]?.expr; + return !expressionFilterManager.filterManagersMap[filterName]?.[0]?.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 f56d8f463bbb..04058ab15e39 100644 --- a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte @@ -20,8 +20,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; @@ -104,7 +103,6 @@ leaderboardMeasures={$leaderboardMeasures} leaderboardShowContextForAllMeasures={$leaderboardShowContextForAllMeasures} {whereFilter} - {dimensionThresholdFilters} {tableWidth} {timeRange} {dimensionColumnWidth} @@ -120,7 +118,7 @@ selectedValues={selectedDimensionValues( client, [metricsViewName], - $dashboardStore.whereFilter, + whereFilter, dimension.name, timeRange.start, timeRange.end, diff --git a/web-common/src/features/dashboards/selectors.ts b/web-common/src/features/dashboards/selectors.ts index 1efb46af765c..c3d028469792 100644 --- a/web-common/src/features/dashboards/selectors.ts +++ b/web-common/src/features/dashboards/selectors.ts @@ -1,5 +1,6 @@ import { createAndExpression, + forEachExpression, matchExpressionByName, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { @@ -32,7 +33,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,9 +205,11 @@ export function hasValidMetricsViewTimeRange( } export function getFiltersForOtherDimensions( - whereFilter: V1Expression, + whereFilter: V1Expression | undefined, dimName: string, ) { + if (!whereFilter) return undefined; + const exprIdx = whereFilter?.cond?.exprs?.findIndex((e) => matchExpressionByName(e, dimName), ); @@ -222,16 +224,17 @@ export function getFiltersForOtherDimensions( 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/loaders/DashboardStateSync.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts index d2da91238f1b..4a7e869198ce 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -21,6 +21,7 @@ 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"; +import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); @@ -83,10 +84,20 @@ export class DashboardStateSync { void this.handleExploreInit(initExploreState.data); }); - this.unsubExploreState = this.exploreStore.subscribe((exploreState) => { - if (!exploreState || !this.initialized) return; - void this.gotoNewState(exploreState); - }); + const fullStateStore = derived( + [this.exploreStore, this.expressionFilterManager.exprByMetricsViewStore], + (s) => s, + ); + this.unsubExploreState = fullStateStore.subscribe( + ([exploreState, exprByMetricsViewStore]) => { + if (!exploreState || !this.initialized) return; + void this.gotoNewState({ + ...exploreState, + whereFilter: + Object.values(exprByMetricsViewStore)[0] ?? createAndExpression([]), + }); + }, + ); setContext(DASHBOARD_STATE_SYNC_KEY, this); } @@ -213,7 +224,6 @@ export class DashboardStateSync { urlSearchParams: URLSearchParams, type: AfterNavigate["type"], ) { - console.log("handleURLChange", type); // Since we call this in afterNavigation, there could be a scenario where navigation completes but data for init isnt loaded yet. // Init already incorporates the url into the state so we can skip this processing. if (this.updating || !this.initialized) return; @@ -267,6 +277,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); @@ -320,7 +331,6 @@ export class DashboardStateSync { * This will check if the url needs to be changed and will navigate to the new url. */ private async gotoNewState(exploreState: ExploreState) { - console.log("gotoNewState"); // Updating state either in handleExploreInit or handleURLChange will synchronously update the state triggering this function. // Since those methods handle redirect themselves we need to skip this logic. // Those methods need to replace the current URL while this does a direct navigation. @@ -358,7 +368,6 @@ 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) { 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/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 6beecd0c54b3..25f9a4bbd1a7 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -33,6 +33,7 @@ import { type PivotMeasureFormatting, type PivotTableMode, } from "../pivot/types"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; export interface MetricsExplorerStoreType { entities: Record; @@ -206,6 +207,7 @@ const metricsViewReducers = { mergePartialExplorerEntity( name: string, partialExploreState: Partial, + expressionFilterManager: ExpressionFilterManager, ) { partialExploreState = structuredClone(partialExploreState); @@ -213,6 +215,17 @@ const metricsViewReducers = { for (const key in partialExploreState) { exploreState[key] = partialExploreState[key]; } + + const mvName = + expressionFilterManager.metricsViewsProvider.metricsViewNames[0]; + if (mvName) { + exploreState.whereFilter = + expressionFilterManager.exprByMetricsView[mvName] ?? + createAndExpression([]); + exploreState.dimensionsWithInlistFilter = + expressionFilterManager.inList; + } + // this hack is needed since what is shown for comparison is not a single source // TODO: use an enum and get rid of this if (!partialExploreState.showTimeComparison) { diff --git a/web-common/src/features/dashboards/stores/filter-utils.ts b/web-common/src/features/dashboards/stores/filter-utils.ts index 9948f10d4e6d..4b23e13d9ca1 100644 --- a/web-common/src/features/dashboards/stores/filter-utils.ts +++ b/web-common/src/features/dashboards/stores/filter-utils.ts @@ -418,7 +418,8 @@ const SupportedOperations = new Set([ V1Operation.OPERATION_NLIKE, ]); -export function isExpressionUnsupported(expression: V1Expression) { +export function isExpressionUnsupported(expression: V1Expression | undefined) { + if (!expression) return false; if ( !expression.cond || !expression.cond.exprs || diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index cbac3e2f4682..f0f5ff68a0fb 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -13,7 +13,7 @@ import { navigationOpen } from "@rilldata/web-common/layout/navigation/Navigation.svelte"; import Resizer from "@rilldata/web-common/layout/Resizer.svelte"; import { onDestroy } from "svelte"; - import { readable, type Readable } from "svelte/store"; + import { derived, readable, type Readable } from "svelte/store"; import { useExploreState } from "web-common/src/features/dashboards/stores/dashboard-stores"; import { DashboardState_ActivePage } from "../../../proto/gen/rill/ui/v1/dashboard_pb"; import { useRuntimeClient } from "../../../runtime-client/v2"; @@ -38,6 +38,7 @@ exploreTimeseriesWidth, tddChartHeight, } from "./dashboard-layout-store"; + import { createAndExpression } from "../stores/filter-utils"; export let exploreName: string; export let metricsViewName: string; @@ -55,6 +56,7 @@ pivot: { showPivot }, }, dashboardStore, + expressionFilterManager, } = StateManagers; const { cloudDataViewer, readOnly } = featureFlags; @@ -67,8 +69,11 @@ const client = useRuntimeClient(); - $: ({ whereFilter, dimensionThresholdFilters, selectedTimeDimension } = - $dashboardStore); + $: ({ selectedTimeDimension } = $dashboardStore); + $: whereFilter = derived( + expressionFilterManager.exprByMetricsViewStore, + (exprByMetricsViewStore) => Object.values(exprByMetricsViewStore)[0], + ); $: extraLeftPadding = !$navigationOpen; @@ -257,8 +262,7 @@ Date: Mon, 10 Aug 2026 17:28:02 +0530 Subject: [PATCH 16/21] Remove cases of whereFilter usage --- .../bookmarks/BookmarksFormDialog.svelte | 40 +-- .../public-urls/CreatePublicURLForm.svelte | 31 +- .../src/features/public-urls/form-utils.ts | 33 +-- .../src/features/alerts/AlertForm.svelte | 2 +- .../src/features/alerts/create-alert-utils.ts | 8 +- .../alerts/criteria-tab/AlertPreview.svelte | 2 +- .../alerts/data-tab/DataPreview.svelte | 2 +- .../canvas/CanvasDashboardWrapper.svelte | 12 +- .../components/SaveDefaultsButton.svelte | 10 +- .../pivot/pivot-click-to-filter.spec.ts | 2 +- .../canvas/filters/CanvasFilters.svelte | 16 + .../filters/canvas-filter-expressions.ts | 17 +- .../inspector/DefaultFilterDisplay.svelte | 8 +- .../features/canvas/stores/canvas-entity.ts | 40 ++- .../dimension-table/DimensionDisplay.svelte | 18 +- .../filters/ExpressionFilterManager.spec.ts | 36 ++- .../filters/ExpressionFilterManager.svelte.ts | 275 ++++++++++-------- .../filters/ExpressionFilterURLSync.svelte.ts | 33 --- .../dashboards/filters/FilterButton.svelte | 99 ------- .../dashboards/filters/Filters.svelte | 20 +- .../MetricsViewFilterManager.svelte.ts | 58 +++- .../dashboards/leaderboard/Leaderboard.svelte | 7 +- .../leaderboard/LeaderboardDisplay.svelte | 4 +- .../proto-state/sparse-proto.spec.ts | 22 +- .../DashboardConfigProvider.svelte.ts | 72 +++++ .../providers/YAMLConfigProvider.svelte.ts | 67 ++--- .../src/features/dashboards/selectors.ts | 7 +- .../StateManagersProvider.svelte | 6 +- .../actions/dimension-filters.ts | 213 -------------- .../loaders/DashboardStateManager.spec.ts | 2 +- .../loaders/DashboardStateSync.ts | 20 +- .../loaders/explore-web-view-store.spec.ts | 55 +++- .../most-recent-explore-state.spec.ts | 2 +- .../selectors/dimension-filters.ts | 24 +- .../state-managers/state-managers.ts | 26 +- .../dashboards/stores/dashboard-stores.ts | 18 +- .../dashboards/stores/filter-utils.ts | 6 +- .../stores/test-data/store-mutations.ts | 142 ++++++--- .../MetricsTimeSeriesCharts.svelte | 24 +- .../invalid-url-state-variations.spec.ts | 11 +- .../url-state/test/url-state-test-utils.ts | 126 ++++++++ .../url-state/url-state-variations.spec.ts | 110 ++++--- .../dashboards/workspace/Dashboard.svelte | 15 +- .../ScheduledReportDialog.svelte | 6 +- 44 files changed, 909 insertions(+), 838 deletions(-) delete mode 100644 web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts delete mode 100644 web-common/src/features/dashboards/filters/FilterButton.svelte create mode 100644 web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts create mode 100644 web-common/src/features/dashboards/url-state/test/url-state-test-utils.ts diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index 6e07015827d5..66f4b56f1b54 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -38,11 +38,10 @@ import { getRpcErrorMessage } from "@rilldata/web-admin/components/errors/error-utils.ts"; import ReadonlyExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ReadonlyExpressionFilters.svelte"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; - import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import { - CanvasConfigProvider, - YAMLConfigProvider, - } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; + CanvasDashboardConfigProvider, + ExploreDashboardConfigProvider, + } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; let { organization, @@ -52,7 +51,6 @@ bookmark, defaultUrlParams, showFiltersOnly, - metricsViewNames, onClose, }: { organization: string; @@ -62,21 +60,21 @@ bookmark: BookmarkEntry | null; defaultUrlParams: URLSearchParams | undefined; showFiltersOnly: boolean; - metricsViewNames: string[]; onClose: () => void; } = $props(); const runtimeClient = useRuntimeClient(); let { name: resourceName, kind: resourceKind } = $derived(resource); - const metricsViewsProvider = new MetricsViewsProvider(runtimeClient, []); - $effect(() => metricsViewsProvider.setMetricsViewNames(metricsViewNames)); + let dashboardConfigProvider = $derived( + resourceKind === ResourceKind.Canvas + ? new CanvasDashboardConfigProvider(runtimeClient, resourceName) + : new ExploreDashboardConfigProvider(runtimeClient, resourceName), + ); let expressionFilterManager = $derived( new ExpressionFilterManager( - metricsViewsProvider, - resourceKind === ResourceKind.Canvas - ? new CanvasConfigProvider(runtimeClient, resourceName) - : new YAMLConfigProvider(), + dashboardConfigProvider.metricsViewsProvider, + dashboardConfigProvider.yamlConfigProvider, ), ); let { setUrlParams } = $derived(expressionFilterManager); @@ -108,14 +106,16 @@ searchParamsObj.get(ExploreStateURLParams.TimeZone) || "UTC"; try { - const promises = metricsViewNames.map((mvName) => - deriveInterval( - timeRange.expression || "", - runtimeClient, - mvName, - timeZone, - ), - ); + const promises = + dashboardConfigProvider.metricsViewsProvider.metricsViewNames.map( + (mvName) => + deriveInterval( + timeRange.expression || "", + runtimeClient, + mvName, + timeZone, + ), + ); const intervals = await Promise.all(promises); let intervalWithLatestEndPoint: diff --git a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte index 104954be5579..f16ea2233745 100644 --- a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte +++ b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte @@ -29,16 +29,15 @@ } from "./form-utils"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; - import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors.ts"; - import { - CanvasConfigProvider, - YAMLConfigProvider, - } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { getDashboardResourceFromPage } from "@rilldata/web-common/features/dashboards/nav-utils.ts"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import ReadonlyExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ReadonlyExpressionFilters.svelte"; import { getLocale } from "@rilldata/web-common/lib/i18n/gen/runtime"; + import { + CanvasDashboardConfigProvider, + ExploreDashboardConfigProvider, + } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; const runtimeClient = useRuntimeClient(); const queryClient = useQueryClient(); @@ -55,12 +54,21 @@ let dashboardResource = $derived(getDashboardResourceFromPage(page)); let isExplore = $derived(dashboardResource?.kind === ResourceKind.Explore); + let dashboardConfigProvider = $derived( + isExplore + ? new ExploreDashboardConfigProvider( + runtimeClient, + dashboardResource?.name, + ) + : new CanvasDashboardConfigProvider( + runtimeClient, + dashboardResource?.name, + ), + ); let expressionFilterManager = $derived( new ExpressionFilterManager( - new MetricsViewsProvider(runtimeClient, []), - isExplore - ? new YAMLConfigProvider() - : new CanvasConfigProvider(runtimeClient, dashboard), + dashboardConfigProvider.metricsViewsProvider, + dashboardConfigProvider.yamlConfigProvider, ), ); let { setUrlParams } = $derived(expressionFilterManager); @@ -71,7 +79,10 @@ let hasSomeFilter = $derived(Object.keys(exprByMetricsView).length > 0); let sanitisedFilterState = $derived( - createFieldsAndStateForKind(dashboardResource?.kind), + createFieldsAndStateForKind( + dashboardResource?.kind, + expressionFilterManager, + ), ); let { fields, sanitizedState, queryTimeStart, queryTimeEnd } = $derived( $sanitisedFilterState, diff --git a/web-admin/src/features/public-urls/form-utils.ts b/web-admin/src/features/public-urls/form-utils.ts index d54dacd6bc71..4833443190f8 100644 --- a/web-admin/src/features/public-urls/form-utils.ts +++ b/web-admin/src/features/public-urls/form-utils.ts @@ -7,6 +7,7 @@ import type { MetricsViewSpecDimension, MetricsViewSpecMeasure, V1ExploreSpec, + V1Expression, } from "@rilldata/web-common/runtime-client"; import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors.ts"; import { derived } from "svelte/store"; @@ -14,6 +15,7 @@ import { page } from "$app/stores"; import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers.ts"; import { useTimeControlStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store.ts"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; export function convertDateToMinutes(date: string) { const now = new Date(); @@ -29,6 +31,7 @@ export function convertDateToMinutes(date: string) { */ export function createFieldsAndStateForKind( resourceKind: ResourceKind | undefined, + expressionFilterManager: ExpressionFilterManager, ) { if (!resourceKind || resourceKind === ResourceKind.Canvas) { return derived(page, (pageState) => { @@ -54,6 +57,7 @@ export function createFieldsAndStateForKind( return derived( [ dashboardStore, + expressionFilterManager.getExprStoreForFirstMetricsView(), visibleMeasures, visibleDimensions, validSpecStore, @@ -61,13 +65,18 @@ export function createFieldsAndStateForKind( ], ([ dashboardState, + { expr }, $visibleMeasures, $visibleDimensions, validSpecState, timeControlState, ]) => { + const metricsView = validSpecState.data?.metricsView ?? {}; + const exploreFields = getExploreFields( dashboardState, + expr, + metricsView.dimensions ?? [], $visibleDimensions, $visibleMeasures, ); @@ -89,12 +98,12 @@ export function createFieldsAndStateForKind( function getExploreFields( exploreState: ExploreState, + expr: V1Expression | undefined, + allDimensions: MetricsViewSpecDimension[], visibleDimensions: MetricsViewSpecDimension[], visibleMeasures: MetricsViewSpecMeasure[], ): string[] | undefined { - const hasFilter = - hasDashboardWhereFilter(exploreState) || - hasDashboardDimensionThresholdFilter(exploreState); + const hasFilter = !!expr?.cond?.exprs?.length; const everythingIsVisible = exploreState.allDimensionsVisible && @@ -104,11 +113,8 @@ function getExploreFields( if (everythingIsVisible) return undefined; // Not specifying any fields means all fields are visible // Check both where and threshold filters for dimensions - const dimensionsWithThresholdFilters = exploreState.dimensionThresholdFilters - .filter((dt) => dt.filters.length > 0) - .map((dt) => dt.name); - const filteredDimensions = getAllIdentifiers(exploreState.whereFilter).concat( - dimensionsWithThresholdFilters, + const filteredDimensions = getAllIdentifiers(expr).filter((i) => + allDimensions?.find((d) => d.name === i), ); return [ @@ -123,14 +129,6 @@ function getExploreFields( ] as string[]; } -function hasDashboardWhereFilter(exploreState: ExploreState) { - return exploreState.whereFilter?.cond?.exprs?.length; -} - -function hasDashboardDimensionThresholdFilter(exploreState: ExploreState) { - return exploreState.dimensionThresholdFilters?.length; -} - /** * Returns the serialized *sanitized* `state` for the current dashboard. * It removes all state that refers to fields that will be hidden, like filters, pivot chips, and visible field keys. @@ -162,9 +160,6 @@ function getSanitizedExploreStateParam( sortDirection: exploreState.sortDirection, // Remove the filters - // whereFilter: dashboard.whereFilter, - // dimensionThresholdFilters: exploreState.dimensionThresholdFilters, - // dimensionFilterExcludeMode: exploreState.dimensionFilterExcludeMode, // There's no need to share filters-in-progress // temporaryFilterName: dashboard.temporaryFilterName, diff --git a/web-common/src/features/alerts/AlertForm.svelte b/web-common/src/features/alerts/AlertForm.svelte index bdcbc6248f8e..06fe71255634 100644 --- a/web-common/src/features/alerts/AlertForm.svelte +++ b/web-common/src/features/alerts/AlertForm.svelte @@ -185,7 +185,7 @@ queryArgsJson: JSON.stringify( getAlertQueryArgsFromFormValues( values, - filters.exprByMetricsView[metricsViewName], + filters.managerByMetricsView[metricsViewName]?.expr, timeControls.toState(), exploreSpec, ), diff --git a/web-common/src/features/alerts/create-alert-utils.ts b/web-common/src/features/alerts/create-alert-utils.ts index a218c4c4f4bc..6491e284f9ff 100644 --- a/web-common/src/features/alerts/create-alert-utils.ts +++ b/web-common/src/features/alerts/create-alert-utils.ts @@ -12,6 +12,8 @@ import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboard import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils.ts"; 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 { page } from "$app/stores"; +import { get } from "svelte/store"; export function getNewAlertInitialFormValues( metricsViewName: string, @@ -71,11 +73,7 @@ export function getNewAlertInitialFiltersFormValues( new MetricsViewsProvider(client, [metricsViewName]), new YAMLConfigProvider(), ); - const fullExpr = mergeDimensionAndMeasureFilters( - exploreState.whereFilter, - exploreState.dimensionThresholdFilters ?? [], - ); - filters.setExprForMetricsView(metricsViewName, fullExpr); + filters.setUrlParams(get(page).url.searchParams); const timeControls = new TimeControls(metricsViewMetadata, { selectedTimeRange: exploreState.selectedTimeRange, diff --git a/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte b/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte index f4675f4504c1..eb5338742011 100644 --- a/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte +++ b/web-common/src/features/alerts/criteria-tab/AlertPreview.svelte @@ -30,7 +30,7 @@ runtimeClient, queryClient, formValues, - filters.exprByMetricsView[formValues.metricsViewName], + filters.managerByMetricsView[formValues.metricsViewName]?.expr, timeControls, ), ); diff --git a/web-common/src/features/alerts/data-tab/DataPreview.svelte b/web-common/src/features/alerts/data-tab/DataPreview.svelte index dd6f60bb45e9..8891ea3fad6c 100644 --- a/web-common/src/features/alerts/data-tab/DataPreview.svelte +++ b/web-common/src/features/alerts/data-tab/DataPreview.svelte @@ -32,7 +32,7 @@ ...formValues, criteria: [], }, - filters.exprByMetricsView[formValues.metricsViewName], + filters.managerByMetricsView[formValues.metricsViewName]?.expr, timeControls, ), ); diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index dbbb05dc9551..7c46e07d8d78 100644 --- a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte +++ b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte @@ -42,16 +42,18 @@ theme, exportMode, expressionFilterManager, - metricsViewsProvider, - yamlConfigProvider, + dashboardProvider, }, } = $derived(getCanvasStore(canvasName, instanceId)); $effect(() => { - yamlConfigProvider.setEditable(builder); + dashboardProvider.yamlConfigProvider.setEditable(builder); }); let missingRequiredFilters = $derived( - getMissingRequiredFilters(expressionFilterManager, yamlConfigProvider), + getMissingRequiredFilters( + expressionFilterManager, + dashboardProvider.yamlConfigProvider, + ), ); let hasMissingRequired = $derived(missingRequiredFilters.length > 0); @@ -112,7 +114,7 @@ {#if hasMissingRequired} {:else}
{ if ( !arrayUnorderedEquals( - Object.keys(yamlConfigProvider.specPinnedFilters), - Object.keys(yamlConfigProvider.pinnedFilters), + Object.keys(dashboardProvider.yamlConfigProvider.specPinnedFilters), + Object.keys(dashboardProvider.yamlConfigProvider.pinnedFilters), ) ) { return false; } if ( !arrayUnorderedEquals( - Object.keys(yamlConfigProvider.specRequiredFilters), - Object.keys(yamlConfigProvider.requiredFilters), + Object.keys(dashboardProvider.yamlConfigProvider.specRequiredFilters), + Object.keys(dashboardProvider.yamlConfigProvider.requiredFilters), ) ) { return false; diff --git a/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts b/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts index 5251b81fda9f..9cecd89b9c4c 100644 --- a/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts +++ b/web-common/src/features/canvas/components/pivot/pivot-click-to-filter.spec.ts @@ -82,7 +82,7 @@ function selectedValues( fm: ExpressionFilterManager, dimensionName: string, ): (string | null)[] { - const expr = fm.exprByMetricsView[PIVOT_TEST_METRICS_NAME]; + const expr = fm.managerByMetricsView[PIVOT_TEST_METRICS_NAME]?.expr; const dimensionExpr = expr?.cond?.exprs?.find( (e) => e.cond?.exprs?.[0]?.ident === dimensionName, ); diff --git a/web-common/src/features/canvas/filters/CanvasFilters.svelte b/web-common/src/features/canvas/filters/CanvasFilters.svelte index 9dd538154a78..bc917be4ecdb 100644 --- a/web-common/src/features/canvas/filters/CanvasFilters.svelte +++ b/web-common/src/features/canvas/filters/CanvasFilters.svelte @@ -8,6 +8,8 @@ import Metadata from "../../dashboards/time-controls/super-pill/components/Metadata.svelte"; import CanvasComparisonPill from "./CanvasComparisonPill.svelte"; import ExpressionFilters from "../../dashboards/filters/ExpressionFilters.svelte"; + import { page } from "$app/state"; + import { goto } from "$app/navigation"; let { maxWidth, @@ -48,6 +50,20 @@ expressionFilterManager, }, } = $derived(getCanvasStore(canvasName, runtimeClient.instanceId)); + // svelte-ignore state_referenced_locally + expressionFilterManager.createListener(); + + function updateUrlParams(params: URLSearchParams) { + expressionFilterManager.setUrlParams(params); + } + $effect(() => updateUrlParams(page.url.searchParams)); + // svelte-ignore state_referenced_locally + expressionFilterManager.on("state-changed", () => { + const newUrl = new URL(page.url); + expressionFilterManager.applyFilterToParams(newUrl.searchParams); + if (newUrl.search === page.url.search) return; + void goto(newUrl); + }); let selectedRange = $derived($rangeStore); let interval = $derived($intervalStore); diff --git a/web-common/src/features/canvas/filters/canvas-filter-expressions.ts b/web-common/src/features/canvas/filters/canvas-filter-expressions.ts index 42d7235dca9a..f63343ae6b86 100644 --- a/web-common/src/features/canvas/filters/canvas-filter-expressions.ts +++ b/web-common/src/features/canvas/filters/canvas-filter-expressions.ts @@ -8,11 +8,12 @@ import type { V1Expression } from "@rilldata/web-common/runtime-client"; export function getCanvasFilters( canvasEntity: CanvasEntity, ): Record | undefined { - const filtersMap = canvasEntity.expressionFilterManager.exprByMetricsView; - // Check if there are any non-empty filters - const hasFilters = Object.values(filtersMap).some( - (expr) => expr?.cond?.exprs && expr.cond.exprs.length > 0, + const hasFilters = Object.values( + canvasEntity.expressionFilterManager.managerByMetricsView, + ).some( + (manager) => + manager.expr?.cond?.exprs && manager.expr.cond.exprs.length > 0, ); if (!hasFilters) { @@ -21,9 +22,11 @@ export function getCanvasFilters( // Convert Map to plain object for API const metricsViewFilters: Record = {}; - Object.entries(filtersMap).forEach(([metricsViewName, expr]) => { - if (expr?.cond?.exprs && expr.cond.exprs.length > 0) { - metricsViewFilters[metricsViewName] = expr; + Object.entries( + canvasEntity.expressionFilterManager.managerByMetricsView, + ).forEach(([metricsViewName, manager]) => { + if (manager.expr?.cond?.exprs && manager.expr?.cond.exprs.length > 0) { + metricsViewFilters[metricsViewName] = manager.expr; } }); diff --git a/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte b/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte index c2f6123dfef5..7278889a31f7 100644 --- a/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte +++ b/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte @@ -20,8 +20,7 @@ defaultTimeRangeStore, defaultComparisonRangeStore, }, - metricsViewsProvider, - yamlConfigProvider, + dashboardProvider, }, } = $derived(getCanvasStore(canvasName, runtimeClient.instanceId)); @@ -40,7 +39,10 @@ : undefined; let defaultFiltersManager = $derived( - new ExpressionFilterManager(metricsViewsProvider, yamlConfigProvider), + new ExpressionFilterManager( + dashboardProvider.metricsViewsProvider, + dashboardProvider.yamlConfigProvider, + ), ); $effect(() => { if (!$specStore.data?.canvas?.defaultPreset?.filterExpr) return; diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index 65812e1c12a0..cdaae6983ccf 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -49,15 +49,10 @@ import { DEFAULT_DASHBOARD_WIDTH, namePrefixFromPath } from "../layout-util"; import { createCustomMapStore } from "@rilldata/web-common/lib/custom-map-store"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { queryServiceConvertExpressionToMetricsSQL } from "@rilldata/web-common/runtime-client"; -import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; -import { - CanvasConfigProvider, - YAMLConfigProvider, -} from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -import { ExpressionFilterURLSync } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts"; import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; import { flattenExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { CanvasDashboardConfigProvider } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; export const lastVisitedState = new Map(); @@ -100,8 +95,7 @@ export class CanvasEntity { // Metrics view selectors metricsView: MetricsViewSelectors; - metricsViewsProvider: MetricsViewsProvider; - yamlConfigProvider: YAMLConfigProvider; + dashboardProvider: CanvasDashboardConfigProvider; // Expression filter manager expressionFilterManager: ExpressionFilterManager; @@ -216,15 +210,15 @@ export class CanvasEntity { this.client, this._metricsViews, ); - this.metricsViewsProvider = new MetricsViewsProvider(this.client, []); - this.yamlConfigProvider = new CanvasConfigProvider(this.client, name); + this.dashboardProvider = new CanvasDashboardConfigProvider( + this.client, + name, + ); this.expressionFilterManager = new ExpressionFilterManager( - this.metricsViewsProvider, - this.yamlConfigProvider, + this.dashboardProvider.metricsViewsProvider, + this.dashboardProvider.yamlConfigProvider, ); - // Create the URL sync component - new ExpressionFilterURLSync(this.expressionFilterManager); this.processSpec(this.spec); } @@ -299,7 +293,6 @@ export class CanvasEntity { if (!validSpec) return; if (metricsViews) this._metricsViews.set(metricsViews); - this.metricsViewsProvider.setMetricsViewNames(Object.keys(metricsViews)); this.checkAndSetFilterEnabled(validSpec); this.checkAndSetFileArtifact(filePath); @@ -325,9 +318,11 @@ export class CanvasEntity { setTimeout(resolve, 100); }); - const pinnedFilters = Object.keys(this.yamlConfigProvider.pinnedFilters); + const pinnedFilters = Object.keys( + this.dashboardProvider.yamlConfigProvider.pinnedFilters, + ); const requiredFilters = Object.keys( - this.yamlConfigProvider.requiredFilters, + this.dashboardProvider.yamlConfigProvider.requiredFilters, ); // Persist pinned and required independently. Render-time treats a filter as @@ -342,10 +337,13 @@ export class CanvasEntity { const timeRange = get(this.timeManager.state.rangeStore); const comparisonOn = get(this.timeManager.state.showTimeComparisonStore); - const metricsViewFilters = this.expressionFilterManager.exprByMetricsView; - const filterNames = Object.keys(metricsViewFilters); - const promises = Object.values(metricsViewFilters).map((expr) => { - const protoExpr = expr as any; // TODO: expand toProto.ts::toExpressionProto to support subquery and convert. + const filterNames = Object.keys( + this.expressionFilterManager.managerByMetricsView, + ); + const promises = Object.values( + this.expressionFilterManager.managerByMetricsView, + ).map((manager) => { + const protoExpr = manager.expr as any; // TODO: expand toProto.ts::toExpressionProto to support subquery and convert. return queryClient.fetchQuery({ queryKey: getQueryServiceConvertExpressionToMetricsSQLQueryKey( this.instanceId, diff --git a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte index 3f934de495fc..c63705d8a308 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte @@ -29,11 +29,14 @@ import DimensionHeader from "./DimensionHeader.svelte"; import DimensionTable from "./DimensionTable.svelte"; import { getDimensionFilterWithSearch } from "./dimension-table-utils"; + import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters.ts"; + import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; const queryLimit = 250; export let timeRange: V1TimeRange; export let comparisonTimeRange: V1TimeRange | undefined; + export let dimensionOnlyFilter: V1Expression | undefined; export let whereFilter: V1Expression | undefined; export let metricsViewName: string; export let visibleMeasureNames: string[]; @@ -64,14 +67,14 @@ $: selectedValues = selectedDimensionValues( client, [metricsViewName], - whereFilter, + dimensionOnlyFilter, dimensionName, timeRange.start, timeRange.end, ); $: filterSet = getDimensionFilterWithSearch( - whereFilter, + dimensionOnlyFilter, $dimensionSearchText, dimensionName, ); @@ -114,7 +117,12 @@ measures: filteredMeasures.filter( (m) => !m.comparisonValue && !m.comparisonDelta && !m.comparisonRatio, ), - where: getFiltersForOtherDimensions(whereFilter, dimensionName), + where: sanitiseExpression( + mergeFilters( + getFiltersForOtherDimensions(dimensionOnlyFilter, dimensionName), + whereFilter, + ), + ), timeRange, }, { @@ -154,13 +162,13 @@ timeRange, comparisonTimeRange, sort, - where: whereFilter, + where: sanitiseExpression(mergeFilters(filterSet, whereFilter)), limit: queryLimit.toString(), offset: "0", }, { query: { - enabled: timeControlsReady && !!filterSet, + enabled: timeControlsReady, }, }, ); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts index 6fae363eaa05..cd8861349e05 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts @@ -373,7 +373,7 @@ describe("setExprForMetricsView / setParamForMetricsView", () => { filterManager.setExprForMetricsView(AD_BIDS_METRICS_NAME, undefined); expect( - filterManager.managerByMetricsView[AD_BIDS_METRICS_NAME], + filterManager.managerByMetricsView[AD_BIDS_METRICS_NAME].expr, ).toBeUndefined(); expect( names(dimensionManagersOf(filterManager, AD_BIDS_MIRROR_METRICS_NAME)), @@ -602,6 +602,34 @@ describe("dimensionFilterAction", () => { ).toBeUndefined(); expect(callback).not.toHaveBeenCalled(); }); + + it("set => unset => set filters should add filter", () => { + const filterManager = createFilterManager(); + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.toggleValue("Google", false), + ); + expect(filterManager.filterManagers.dimensions[0]).not.toBeUndefined(); + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + ]); + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.toggleValue("Google", false), + ); + expect(filterManager.filterManagers.dimensions[0]).toBeUndefined(); + + filterManager.dimensionFilterAction( + AD_BIDS_PUBLISHER_DIMENSION, + (manager) => manager.toggleValue("Google", false), + ); + expect(filterManager.filterManagers.dimensions[0]).not.toBeUndefined(); + expect(filterManager.filterManagers.dimensions[0].selectedValues).toEqual([ + "Google", + ]); + }); }); describe("clear", () => { @@ -619,7 +647,9 @@ describe("clear", () => { filterManager.clear(); expect(filterManager.temporaryFilterName).toBeUndefined(); - expect(filterManager.managerByMetricsView).toEqual({}); + expect( + Object.values(filterManager.managerByMetricsView).map((m) => m.expr), + ).toEqual([undefined, undefined]); expect(filterManager.filterManagers.dimensions).toEqual([]); expect(filterManager.filterManagers.measures).toEqual([]); }); @@ -894,7 +924,7 @@ describe("exprByMetricsView", () => { expect(filterManager.hasSomeFilter).toBe(false); }); - it("picks up a filter added outside the params", () => { + it("picks up a filter added outside the params", async () => { const filterManager = createFilterManager(); filterManager.dimensionFilterAction( diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 0eea7e9433fc..1eef02c2dad3 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -1,15 +1,11 @@ import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts"; -import { - type V1Expression, - V1Operation, -} from "@rilldata/web-common/runtime-client"; +import { type V1Expression } from "@rilldata/web-common/runtime-client"; import { convertExpressionToFilterParam, convertFilterParamToExpression, } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; import { createAndExpression, - isExpressionUnsupported, maybeWrapAndExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; import { @@ -23,18 +19,18 @@ import { } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; -import { writable, type Writable } from "svelte/store"; +import { toStore, writable, type Writable } from "svelte/store"; import { type AnyManager, type DimensionOrMeasureManager, MetricsViewFilterManager, } from "@rilldata/web-common/features/dashboards/filters/MetricsViewFilterManager.svelte.ts"; import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; +import { EventEmitter } from "@rilldata/web-common/lib/event-emitter.ts"; -type ParsedUrlParamsByMV = Record< - MetricsViewName, - ReturnType ->; +type ExpressionFilterManagerEvents = { + "state-changed": void; +}; /** * Filter managers for the chips in a filter bar. @@ -56,7 +52,11 @@ export class ExpressionFilterManager { // Name of the filter added from the filter menu. Its chip opens as soon as it is rendered. public temporaryFilterName: string | undefined = $state(undefined); - public managerByMetricsView: Record; + // Filter manager per metrics view. Will contain parsed and validated expression. + public managerByMetricsView: Record< + MetricsViewName, + MetricsViewFilterManager + >; public filterManagers: { measures: MeasureFilterManager[]; dimensions: DimensionFilterManager[]; @@ -66,9 +66,15 @@ export class ExpressionFilterManager { DimensionOrMeasureManager[] >; - // Raw parsed expression from url params. Validated expression is stored in exprByMetricsView. - private prevUrlParams = $state(""); - private parsedUrlParams = $state({}); + private events = new EventEmitter(); + public readonly on = this.events.on.bind( + this.events, + ) as typeof this.events.on; + + // Raw filter param per metrics view. + private paramByMetricsView = $state>({}); + // Filter param based on managers. + private paramByManager: Record; public constructor( public readonly metricsViewsProvider: MetricsViewsProvider, @@ -77,23 +83,38 @@ export class ExpressionFilterManager { this.managerByMetricsView = $derived.by(() => { const existingManagers = new Map(); return Object.fromEntries( - Object.entries(this.parsedUrlParams) - .map(([mvName, { expr, dimensionsWithInlistFilter }]) => [ - mvName, - MetricsViewFilterManager.parse( - this.metricsViewsProvider, + Object.entries(this.paramByMetricsView) + .map(([mvName, param]) => { + const { expr, dimensionsWithInlistFilter } = + parseFilterParam(param); + return [ mvName, - maybeWrapAndExpression(expr), - dimensionsWithInlistFilter, - existingManagers, - ), - ]) + MetricsViewFilterManager.parse( + this.metricsViewsProvider, + mvName, + maybeWrapAndExpression(expr) ?? createAndExpression([]), + dimensionsWithInlistFilter, + existingManagers, + ), + ]; + }) .filter(([, manager]) => !!manager) as [ string, MetricsViewFilterManager, ][], ); }); + this.paramByManager = $derived( + Object.fromEntries( + Object.entries(this.managerByMetricsView).map(([mvName, manager]) => [ + mvName, + manager.param, + ]), + ), + ); + this.isComplexFilter = $derived( + Object.values(this.managerByMetricsView).some((m) => m.isComplexFilter), + ); this.filterManagers = $derived.by(() => this.buildFilterManagers(this.buildParamFilterManagers()), @@ -114,67 +135,100 @@ export class ExpressionFilterManager { ]), ); - this.exprByMetricsView = $derived.by(() => - this.buildExpressionsByMetricsView(), + this.exprByMetricsView = $derived( + Object.fromEntries( + Object.entries(this.managerByMetricsView) + .map(([mvName, manager]) => [mvName, manager.expr]) + .filter(([, manager]) => !!manager) as [string, V1Expression][], + ), ); this.hasSomeFilter = $derived( Object.keys(this.exprByMetricsView).length > 0, ); + // Init with empty url params + this.setUrlParams(new URLSearchParams(), true); + } + + public createListener() { $effect(() => { this.exprByMetricsViewStore.set(this.exprByMetricsView); }); + + $effect(() => { + const paramsAreEqual = recordsMatch( + this.paramByManager, + this.paramByMetricsView, + this.metricsViewsProvider.metricsViewNames, + ); + if (!paramsAreEqual) { + this.events.emit("state-changed"); + } + }); } - public setUrlParams = (searchParams: URLSearchParams) => { - const newParsedUrlParams = {} as ParsedUrlParamsByMV; - const newParams = [] as string[]; - let isSomeComplexFilter = false; + public clone() { + const cloned = new ExpressionFilterManager( + this.metricsViewsProvider, + this.yamlConfigProvider, + ); + + const newUrlSearch = new URLSearchParams(); + this.applyFilterToParams(newUrlSearch); + cloned.setUrlParams(newUrlSearch); + + return cloned; + } + + public setUrlParams = (searchParams: URLSearchParams, force = false) => { + const newParamByMetricsView = {} as Record; const singularFilter = searchParams.get(ExploreStateURLParams.Filters); this.metricsViewsProvider.metricsViewNames.forEach((name: string) => { const paramKey = `${ExploreStateURLParams.Filters}.${name}`; - const filterString = searchParams.get(paramKey) ?? singularFilter ?? ""; - newParams.push(`${paramKey}=${filterString}`); - - const { expr, dimensionsWithInlistFilter } = - convertFilterParamToExpression(filterString); - const wrappedExpr = maybeWrapAndExpression(expr); - isSomeComplexFilter ||= expr - ? isExpressionUnsupported(wrappedExpr!) - : false; - newParsedUrlParams[name] = { - expr: wrappedExpr, - dimensionsWithInlistFilter, - }; + newParamByMetricsView[name] = + searchParams.get(paramKey) ?? singularFilter ?? ""; }); - const newParam = newParams.join("&"); - console.log("setUrlParams", newParam); - if (this.prevUrlParams === newParam) return; - this.prevUrlParams = newParam; - this.parsedUrlParams = newParsedUrlParams; - this.isComplexFilter = isSomeComplexFilter; + const unchanged = recordsMatch( + this.paramByMetricsView, + newParamByMetricsView, + this.metricsViewsProvider.metricsViewNames, + ); + if (unchanged && !force) return; + + this.paramByMetricsView = newParamByMetricsView; this.temporaryFilterName = undefined; }; public setExprForMetricsView(mvName: string, expr: V1Expression | undefined) { - const prevURLSearch = new URLSearchParams(this.prevUrlParams); - - const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; - const filterParam = expr ? convertExpressionToFilterParam(expr) : ""; - prevURLSearch.set(paramKey, filterParam); - - this.setUrlParams(prevURLSearch); + this.setParamForMetricsView( + mvName, + expr ? convertExpressionToFilterParam(expr) : "", + ); } public setParamForMetricsView(mvName: string, param: string) { - const prevURLSearch = new URLSearchParams(this.prevUrlParams); + if (this.paramByMetricsView[mvName] === param) return; - const paramKey = `${ExploreStateURLParams.Filters}.${mvName}`; - prevURLSearch.set(paramKey, param); + this.paramByMetricsView = { ...this.paramByMetricsView, [mvName]: param }; + this.temporaryFilterName = undefined; + } - this.setUrlParams(prevURLSearch); + public applyFilterToParams( + searchParams: URLSearchParams, + singleParam: boolean = false, + ) { + Object.entries(this.paramByManager).forEach(([key, value]) => { + const paramKey = singleParam + ? ExploreStateURLParams.Filters + : `${ExploreStateURLParams.Filters}.${key}`; + if (value) { + searchParams.set(paramKey, value); + } else { + searchParams.delete(paramKey); + } + }); } /** Adds a chip for a filter that has no value yet. The chip opens as soon as it is rendered. */ @@ -198,12 +252,9 @@ export class ExpressionFilterManager { if (!(dimensionFilterManager instanceof DimensionFilterManager)) return; const ret = callback(dimensionFilterManager); - if (dimensionFilterManager.expr && !(name in this.filterManagersMap)) { - this.filterManagers = { - ...this.filterManagers, - dimensions: [...this.filterManagers.dimensions, dimensionFilterManager], - }; - } + Object.values(this.managerByMetricsView).forEach((m) => + m.maybeAddDimensionFilter(dimensionFilterManager), + ); return ret; } @@ -273,6 +324,23 @@ export class ExpressionFilterManager { } } + public getExprStoreForFirstMetricsView() { + return toStore(() => { + const exprs = Object.values(this.managerByMetricsView)[0]; + return { + expr: exprs?.expr, + dimensionOnlyExpr: exprs?.dimensionOnlyExpr, + }; + }); + } + + public getExprStoreForMetricsView(mvName: string) { + return toStore(() => ({ + expr: this.managerByMetricsView[mvName]?.expr, + dimensionOnlyExpr: this.managerByMetricsView[mvName]?.dimensionOnlyExpr, + })); + } + /** Managers for the filters in the param, keyed by name and in param order. */ private buildParamFilterManagers() { const dimensionsMap = {} as Record; @@ -356,57 +424,6 @@ export class ExpressionFilterManager { return { measures, dimensions }; } - /** - * The filter to send with queries for each metrics view. - * - * The param contributes a whole expression per metrics view, so any nesting it has survives. - * Chips the param does not cover yet, the ones from the filter menu or a chart interaction, - * are added to every metrics view that defines them. - */ - private buildExpressionsByMetricsView() { - const exprsByMetricsView = {} as Record; - const addExpr = (metricsViewName: string, expr: V1Expression) => { - exprsByMetricsView[metricsViewName] ??= []; - // Flattening a top level AND keeps the result a single flat AND in the common case. - if (expr.cond?.op === V1Operation.OPERATION_AND) { - exprsByMetricsView[metricsViewName].push(...(expr.cond.exprs ?? [])); - } else { - exprsByMetricsView[metricsViewName].push(expr); - } - }; - - const managersInParam = new Set(); - Object.entries(this.managerByMetricsView).forEach( - ([metricsViewName, mvManager]) => { - Object.values(mvManager.managerLookup) - .flat() - .forEach((manager) => managersInParam.add(manager)); - if (mvManager.expr) addExpr(metricsViewName, mvManager.expr); - }, - ); - - [...this.filterManagers.dimensions, ...this.filterManagers.measures] - .filter((manager) => !!manager.expr && !managersInParam.has(manager)) - .forEach((manager) => { - const specs = - this.metricsViewsProvider.dimensionSpecs[manager.name] ?? - this.metricsViewsProvider.measureSpecs[manager.name] ?? - {}; - Object.keys(specs).forEach((metricsViewName) => - addExpr(metricsViewName, manager.expr!), - ); - }); - - return Object.fromEntries( - Object.entries(exprsByMetricsView) - .filter(([, exprs]) => exprs.length > 0) - .map(([metricsViewName, exprs]) => [ - metricsViewName, - createAndExpression(exprs), - ]), - ); - } - private createMeasureFilterManager(name: string, initExpr?: V1Expression) { const measureSpecs = this.metricsViewsProvider.measureSpecs[name]; if (!measureSpecs) return undefined; @@ -435,8 +452,32 @@ export class ExpressionFilterManager { } } +/** + * The parser throws on a malformed param. + * Converting the url to explore state reports it as an error, so drop it here instead of throwing. + */ +function parseFilterParam(param: string) { + try { + return convertFilterParamToExpression(param); + } catch { + return { expr: undefined, dimensionsWithInlistFilter: [] }; + } +} + function sortFilterIterator(arr: string[], tempName: string | undefined) { const newArr = [...arr]; if (tempName) newArr.push(tempName); return newArr.sort((a, b) => (a > b ? 1 : -1)); } + +export function recordsMatch( + rec1: Record, + rec2: Record, + keys: string[], +) { + const keysMatch = + keys.length === Object.keys(rec1).length && + keys.length === Object.keys(rec2).length; + if (!keysMatch) return false; + return keys.every((key) => rec1[key] === rec2[key]); +} diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts deleted file mode 100644 index c4c0d1c6927e..000000000000 --- a/web-common/src/features/dashboards/filters/ExpressionFilterURLSync.svelte.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { page } from "$app/state"; -import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; -import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; -import { goto } from "$app/navigation"; - -export class ExpressionFilterURLSync { - public constructor(expressionFilterManager: ExpressionFilterManager) { - $effect(() => expressionFilterManager.setUrlParams(page.url.searchParams)); - - $effect(() => { - const newUrl = new URL(page.url); - - expressionFilterManager.metricsViewsProvider.metricsViewNames.forEach( - (mvName) => { - const key = `${ExploreStateURLParams.Filters}.${mvName}`; - if (mvName in expressionFilterManager.exprByMetricsView) { - const param = convertExpressionToFilterParam( - expressionFilterManager.exprByMetricsView[mvName], - expressionFilterManager.inList, - ); - newUrl.searchParams.set(key, param); - } else { - newUrl.searchParams.delete(key); - } - }, - ); - - if (newUrl.search === page.url.search) return; - void goto(newUrl); - }); - } -} diff --git a/web-common/src/features/dashboards/filters/FilterButton.svelte b/web-common/src/features/dashboards/filters/FilterButton.svelte deleted file mode 100644 index 6a7bae4c3efb..000000000000 --- a/web-common/src/features/dashboards/filters/FilterButton.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - - - - {#snippet child({ props })} - - - {m.dashboard_add_filter()} - - {/snippet} - - - { - setTemporaryFilterName(name); - }} - {selectableGroups} - selectedItems={[]} - {side} - /> - - - diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 10d2dabeecab..08c74913a5d6 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -17,7 +17,6 @@ import { invalidationForMetricsViewData } from "@rilldata/web-common/runtime-client/invalidation.ts"; import { DateTime, Duration, Interval } from "luxon"; import { getStateManagers } from "../state-managers/state-managers"; - import { applyDimensionInListMode as applyDimensionInListModeDirectly } from "../state-managers/actions/dimension-filters"; import { metricsExplorerStore, useExploreState, @@ -40,6 +39,8 @@ import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import ExpressionFilters from "./ExpressionFilters.svelte"; + import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; + import { recordsMatch } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; const { rillTime } = featureFlags; @@ -65,6 +66,7 @@ dashboardStore, expressionFilterManager, } = StateManagers; + expressionFilterManager.createListener(); const timeControlsStore = useTimeControlStore(StateManagers); @@ -364,13 +366,17 @@ ) { if (!dashboardStateSync) return false; - const exploreState = structuredClone($dashboardStore); - applyDimensionInListModeDirectly( - { dashboard: exploreState }, - dimensionName, - values, + const tempFilterManger = expressionFilterManager.clone(); + tempFilterManger.dimensionFilterAction(dimensionName, (m) => + m.setInList(values, m.exclude), ); - const url = dashboardStateSync.getUrlForExploreState(exploreState); + + const exploreState = structuredClone($dashboardStore); + exploreState.whereFilter = + Object.values(tempFilterManger.managerByMetricsView)[0]?.expr ?? + createAndExpression([]); + + const url = dashboardStateSync.getUrlForExploreState($dashboardStore); return isUrlTooLong(url); } diff --git a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts index b6935a310b90..8eeec0e648e6 100644 --- a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts @@ -19,15 +19,22 @@ export type DimensionOrMeasureManager = | DimensionFilterManager | MeasureFilterManager; export type AnyManager = DimensionOrMeasureManager | MetricsViewFilterManager; +export type AllManagers = { + dimensionManagers: DimensionFilterManager[]; + measureManagers: MeasureFilterManager[]; + joinerManagers: MetricsViewFilterManager[]; +}; /** * Manager for an AND/OR expression for a metrics view. */ export class MetricsViewFilterManager { public expr: V1Expression | undefined; + public dimensionOnlyExpr: V1Expression | undefined; // String representation of the filter expression. Used to check duplicate expressions across metrics views. public param: string; + public managers: AllManagers; public managerLookup: Record< string, (DimensionFilterManager | MeasureFilterManager)[] @@ -37,17 +44,17 @@ export class MetricsViewFilterManager { public constructor( public readonly metricsViewName: string, + private readonly metricsViewsProvider: MetricsViewsProvider, public type: | typeof V1Operation.OPERATION_AND | typeof V1Operation.OPERATION_OR, - public readonly managers: { - dimensionManagers: DimensionFilterManager[]; - measureManagers: MeasureFilterManager[]; - joinerManagers: MetricsViewFilterManager[]; - }, - private readonly inList: string[], + managers: AllManagers, + public readonly inList: string[], ) { - this.expr = $derived(this.buildExpressionsByMetricsView()); + this.managers = $state(managers); + + this.expr = $derived(this.buildExpression()); + this.dimensionOnlyExpr = $derived(this.buildDimensionOnlyExpression()); this.param = $derived( this.expr ? convertExpressionToFilterParam(this.expr, this.inList) : "", ); @@ -108,6 +115,7 @@ export class MetricsViewFilterManager { return new MetricsViewFilterManager( metricsViewName, + metricsViewsProvider, op, { dimensionManagers, @@ -155,7 +163,28 @@ export class MetricsViewFilterManager { } } - private buildExpressionsByMetricsView() { + public maybeAddDimensionFilter( + dimensionFilterManager: DimensionFilterManager, + ) { + const dimensionName = dimensionFilterManager.name; + const alreadyAdded = !!this.managerLookup[dimensionName]; + const dimensionNotInMV = + !this.metricsViewsProvider.dimensionSpecs[dimensionName]?.[ + this.metricsViewName + ]; + if (!dimensionFilterManager.expr || alreadyAdded || dimensionNotInMV) + return; + + this.managers = { + ...this.managers, + dimensionManagers: [ + ...this.managers.dimensionManagers, + dimensionFilterManager, + ], + }; + } + + private buildExpression() { const dimensionExprs = this.managers.dimensionManagers .map((dfm) => dfm.expr) .filter(Boolean) as V1Expression[]; @@ -175,6 +204,19 @@ export class MetricsViewFilterManager { } } + private buildDimensionOnlyExpression() { + const dimensionExprs = this.managers.dimensionManagers + .map((dfm) => dfm.expr) + .filter(Boolean) as V1Expression[]; + if (dimensionExprs.length === 0) return undefined; + + if (this.type === V1Operation.OPERATION_AND) { + return createAndExpression(dimensionExprs); + } else { + return createOrExpression(dimensionExprs); + } + } + private buildManagerLookup() { const lookup = {} as Record< string, diff --git a/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte b/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte index 67b69b2364a8..45583d2efa7a 100644 --- a/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte +++ b/web-common/src/features/dashboards/leaderboard/Leaderboard.svelte @@ -49,6 +49,7 @@ export let timeRange: V1TimeRange; export let comparisonTimeRange: V1TimeRange | undefined; export let selectedValues: ReturnType; + export let dimensionOnlyFilter: V1Expression | undefined; export let whereFilter: V1Expression | undefined; export let leaderboardSortByMeasureName: string; export let leaderboardMeasures: MetricsViewSpecMeasure[]; @@ -135,10 +136,10 @@ $: atLeastOneActive = Boolean($selectedValues.data?.length); - $: isComplexFilter = isExpressionUnsupported(whereFilter); + $: isComplexFilter = isExpressionUnsupported(dimensionOnlyFilter); $: where = isComplexFilter - ? whereFilter - : getFiltersForOtherDimensions(whereFilter, dimensionName); + ? dimensionOnlyFilter + : getFiltersForOtherDimensions(dimensionOnlyFilter, dimensionName); $: measures = [ ...getMeasuresForDimensionOrLeaderboardDisplay( diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte index 04058ab15e39..77a67f6abfd0 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,6 +19,7 @@ } from "./leaderboard-widths"; export let metricsViewName: string; + export let dimensionOnlyFilter: V1Expression | undefined; export let whereFilter: V1Expression | undefined; export let timeRange: V1TimeRange; export let comparisonTimeRange: V1TimeRange | undefined; @@ -50,7 +50,6 @@ comparison: { toggleComparisonDimension }, }, exploreName, - dashboardStore, expressionFilterManager, } = StateManagers; @@ -102,6 +101,7 @@ leaderboardSortByMeasureName={$leaderboardSortByMeasureName} leaderboardMeasures={$leaderboardMeasures} leaderboardShowContextForAllMeasures={$leaderboardShowContextForAllMeasures} + {dimensionOnlyFilter} {whereFilter} {tableWidth} {timeRange} 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/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 index b66a22b2a321..eb09dead26c9 100644 --- a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -1,8 +1,4 @@ -import { - createQueryServiceResolveCanvas, - type V1Expression, -} from "@rilldata/web-common/runtime-client"; -import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { type V1Expression } from "@rilldata/web-common/runtime-client"; /** * A provider for YAML only configuration. These are only mutable during yaml editing. @@ -13,10 +9,29 @@ export class YAMLConfigProvider { public specPinnedFilters = $state>({}); public requiredFilters = $state>({}); public specRequiredFilters = $state>({}); + public editable: boolean = false; public cleanup: (() => void) | undefined = undefined; - public constructor(public editable: boolean = false) {} + 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; @@ -30,43 +45,3 @@ export class YAMLConfigProvider { this.requiredFilters[filter] = !this.requiredFilters[filter]; } } - -export class CanvasConfigProvider extends YAMLConfigProvider { - public constructor( - runtimeClient: RuntimeClient, - canvasName: string, - editable: boolean = false, - ) { - super(editable); - - const resolveCanvasQuery = createQueryServiceResolveCanvas(runtimeClient, { - canvas: canvasName, - }); - this.cleanup = resolveCanvasQuery.subscribe((resolveCanvasResp) => { - const canvasSpec = - resolveCanvasResp.data?.canvas?.canvas?.state?.validSpec ?? {}; - - this.defaultFilters = Object.fromEntries( - Object.entries(canvasSpec.defaultPreset?.filterExpr ?? {}).map( - ([mv, sqlFilter]) => [mv, sqlFilter.expression], - ), - ); - - const pinnedFilters = canvasSpec.pinnedFilters - ? Object.fromEntries( - canvasSpec.pinnedFilters.map((filter) => [filter, true]), - ) - : {}; - this.pinnedFilters = { ...pinnedFilters }; - this.specPinnedFilters = { ...pinnedFilters }; - - const requiredFilters = canvasSpec.requiredFilters - ? Object.fromEntries( - canvasSpec.requiredFilters.map((filter) => [filter, true]), - ) - : {}; - this.requiredFilters = { ...requiredFilters }; - this.specRequiredFilters = { ...requiredFilters }; - }); - } -} diff --git a/web-common/src/features/dashboards/selectors.ts b/web-common/src/features/dashboards/selectors.ts index c3d028469792..fc12af9dcd59 100644 --- a/web-common/src/features/dashboards/selectors.ts +++ b/web-common/src/features/dashboards/selectors.ts @@ -1,6 +1,7 @@ import { createAndExpression, forEachExpression, + isSubqueryExpression, matchExpressionByName, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { @@ -210,14 +211,14 @@ export function getFiltersForOtherDimensions( ) { if (!whereFilter) return undefined; - const exprIdx = whereFilter?.cond?.exprs?.findIndex((e) => - matchExpressionByName(e, dimName), + 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), ) ?? [], ); } 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 68352e517415..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,214 +1 @@ -import { page } from "$app/stores"; -import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; -import { - createInExpression, - createLikeExpression, - getValuesInExpression, -} 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 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 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 = {}; 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/DashboardStateSync.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts index 4a7e869198ce..102130029eea 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -21,7 +21,6 @@ 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"; -import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); @@ -84,20 +83,10 @@ export class DashboardStateSync { void this.handleExploreInit(initExploreState.data); }); - const fullStateStore = derived( - [this.exploreStore, this.expressionFilterManager.exprByMetricsViewStore], - (s) => s, - ); - this.unsubExploreState = fullStateStore.subscribe( - ([exploreState, exprByMetricsViewStore]) => { - if (!exploreState || !this.initialized) return; - void this.gotoNewState({ - ...exploreState, - whereFilter: - Object.values(exprByMetricsViewStore)[0] ?? createAndExpression([]), - }); - }, - ); + this.unsubExploreState = this.exploreStore.subscribe((exploreState) => { + if (!exploreState || !this.initialized) return; + void this.gotoNewState(exploreState); + }); setContext(DASHBOARD_STATE_SYNC_KEY, this); } @@ -368,6 +357,7 @@ 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) { 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/most-recent-explore-state.spec.ts b/web-common/src/features/dashboards/state-managers/most-recent-explore-state.spec.ts index 725074f34920..ae131ecabd5f 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 @@ -25,7 +25,7 @@ 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 } 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, 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 20eef1c8fa73..582365ab63f1 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 @@ -96,23 +96,6 @@ export const useSelectedValuesForCompareDimension = (ctx: StateManagers) => { ) as ReturnType; }; -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) => { @@ -209,9 +192,4 @@ export function getDimensionFilters( ); } -export const dimensionFilterSelectors = { - /** - * Check if a dimension has any filter - */ - dimensionHasFilter, -}; +export const dimensionFilterSelectors = {}; 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 a80899994f1f..ffc20be23cd5 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -34,8 +34,7 @@ import { type ContextColWidths, } from "../leaderboard-context-column"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -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 { ExploreDashboardConfigProvider } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; export type StateManagers = { runtimeClient: RuntimeClient; @@ -168,14 +167,23 @@ export function createStateManagers({ }, ); - const metricsViewProvider = new MetricsViewsProvider(runtimeClient, [ - metricsViewName, - ]); + const dashboardProvider = new ExploreDashboardConfigProvider( + runtimeClient, + exploreName, + ); const expressionFilterManager = new ExpressionFilterManager( - metricsViewProvider, - new YAMLConfigProvider(), + dashboardProvider.metricsViewsProvider, + dashboardProvider.yamlConfigProvider, ); + const stateChangeUnsub = expressionFilterManager.on("state-changed", () => { + metricsExplorerStore.mergePartialExplorerEntity( + exploreName, + {}, + expressionFilterManager, + ); + }); + return { runtimeClient, metricsViewName: metricsViewNameStore, @@ -206,7 +214,9 @@ export function createStateManagers({ defaultExploreState, expressionFilterManager, cleanup: () => { - metricsViewProvider.cleanup(); + dashboardProvider.cleanup?.(); + dashboardProvider.metricsViewsProvider.cleanup(); + stateChangeUnsub(); }, }; } diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 25f9a4bbd1a7..3009cc570000 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -125,12 +125,6 @@ function syncDimensions(explore: V1ExploreSpec, exploreState: ExploreState) { const dimensionsSet = new Set(explore.dimensions ?? []); const measuresSet = new Set(explore.measures ?? []); - exploreState.whereFilter = - filterExpressions(exploreState.whereFilter, (e) => { - if (!e.cond?.exprs?.length) return true; - return dimensionsSet.has(e.cond.exprs[0].ident!); - }) ?? createAndExpression([]); - if ( exploreState.selectedDimensionName && !dimensionsSet.has(exploreState.selectedDimensionName) @@ -165,10 +159,6 @@ function syncDimensions(explore: V1ExploreSpec, exploreState: ExploreState) { const metricsViewReducers = { init(name: string, initState: ExploreState) { update((state) => { - // TODO: revisit this during the url state / restore user refactor - initState.dimensionFilterExcludeMode = includeExcludeModeFromFilters( - initState.whereFilter, - ); state.entities[name] = structuredClone(initState); state.entities[name].name = name; @@ -197,9 +187,6 @@ const metricsViewReducers = { if (!partial.showTimeComparison) { exploreState.showTimeComparison = false; } - exploreState.dimensionFilterExcludeMode = includeExcludeModeFromFilters( - partial.whereFilter, - ); correctExploreState(metricsView, exploreState); }); }, @@ -220,7 +207,7 @@ const metricsViewReducers = { expressionFilterManager.metricsViewsProvider.metricsViewNames[0]; if (mvName) { exploreState.whereFilter = - expressionFilterManager.exprByMetricsView[mvName] ?? + expressionFilterManager.managerByMetricsView[mvName].expr ?? createAndExpression([]); exploreState.dimensionsWithInlistFilter = expressionFilterManager.inList; @@ -231,9 +218,6 @@ const metricsViewReducers = { if (!partialExploreState.showTimeComparison) { exploreState.showTimeComparison = false; } - exploreState.dimensionFilterExcludeMode = includeExcludeModeFromFilters( - partialExploreState.whereFilter, - ); // Partial comes from getMergedExploreState and is already corrected }); }, diff --git a/web-common/src/features/dashboards/stores/filter-utils.ts b/web-common/src/features/dashboards/stores/filter-utils.ts index 4b23e13d9ca1..a89b40b3cf26 100644 --- a/web-common/src/features/dashboards/stores/filter-utils.ts +++ b/web-common/src/features/dashboards/stores/filter-utils.ts @@ -315,9 +315,13 @@ export const matchExpressionByName = (e: V1Expression, name: string) => { return e.cond?.exprs?.[0].ident === name; }; +export function isSubqueryExpression(e: V1Expression): boolean { + return !!e.cond?.exprs?.[0]?.subquery; +} + export const sanitiseExpression = ( where: V1Expression | undefined, - having: V1Expression | undefined, + having: V1Expression | undefined = undefined, ) => { if (!having) { if (!where?.cond?.exprs?.length) return undefined; diff --git a/web-common/src/features/dashboards/stores/test-data/store-mutations.ts b/web-common/src/features/dashboards/stores/test-data/store-mutations.ts index ca23c7d60b9f..53a14c9d8b03 100644 --- a/web-common/src/features/dashboards/stores/test-data/store-mutations.ts +++ b/web-common/src/features/dashboards/stores/test-data/store-mutations.ts @@ -1,20 +1,22 @@ +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { + mapMeasureFilterToExpr, + type MeasureFilterEntry, +} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; import { MeasureFilterOperation, MeasureFilterType, } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options"; import { PivotChipType } from "@rilldata/web-common/features/dashboards/pivot/types"; -import { - applyDimensionContainsMode, - applyDimensionInListMode, - removeDimensionFilter, - toggleDimensionValueSelection, -} from "@rilldata/web-common/features/dashboards/state-managers/actions/dimension-filters"; import { handleDimensionMeasureColumnHeaderClick } from "@rilldata/web-common/features/dashboards/state-managers/actions/dimension-table.ts"; import { setPrimaryDimension, toggleDimensionVisibility, } from "@rilldata/web-common/features/dashboards/state-managers/actions/dimensions"; -import { clearAllFilters } from "@rilldata/web-common/features/dashboards/state-managers/actions/filters"; +import { + clearAllFilters, + setFilter, +} from "@rilldata/web-common/features/dashboards/state-managers/actions/filters"; import { removeMeasureFilter, setMeasureFilter, @@ -32,6 +34,7 @@ import { import { createAndExpression, createInExpression, + createSubQueryExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { AD_BIDS_BID_PRICE_MEASURE, @@ -48,6 +51,7 @@ import { RandomPublishers, } from "@rilldata/web-common/features/dashboards/stores/test-data/random"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; +import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters"; import { type DashboardTimeControls, type TimeRange, @@ -65,35 +69,64 @@ import { toggleLeaderboardShowContextForAllMeasures, } from "../../state-managers/actions/leaderboard"; -export type TestDashboardMutation = (mut: DashboardMutables) => void; -export const AD_BIDS_APPLY_PUB_DIMENSION_FILTER: TestDashboardMutation = ( - mut, -) => toggleDimensionValueSelection(mut, AD_BIDS_PUBLISHER_DIMENSION, "Google"); -export const AD_BIDS_REMOVE_PUB_DIMENSION_FILTER: TestDashboardMutation = ( - mut, -) => removeDimensionFilter(mut, AD_BIDS_PUBLISHER_DIMENSION); -export const AD_BIDS_APPLY_DOM_DIMENSION_FILTER: TestDashboardMutation = ( - mut, -) => toggleDimensionValueSelection(mut, AD_BIDS_DOMAIN_DIMENSION, "google.com"); +/** + * Mutables handed to a test mutation. + * + * `filterManager` is only set when the test passes one to {@link applyMutationsToDashboard}, + * so only reach for it from a filter mutation. + */ +export type TestDashboardMutables = DashboardMutables & { + filterManager: ExpressionFilterManager; +}; +export type TestDashboardMutation = (mut: TestDashboardMutables) => void; +export const AD_BIDS_APPLY_PUB_DIMENSION_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.dimensionFilterAction(AD_BIDS_PUBLISHER_DIMENSION, (manager) => + manager.toggleValue("Google", false), + ); +}; +export const AD_BIDS_REMOVE_PUB_DIMENSION_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.dimensionFilterAction(AD_BIDS_PUBLISHER_DIMENSION, (manager) => + manager.clear(), + ); +}; +export const AD_BIDS_APPLY_DOM_DIMENSION_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.dimensionFilterAction(AD_BIDS_DOMAIN_DIMENSION, (manager) => + manager.toggleValue("google.com", false), + ); +}; export const AD_BIDS_LARGE_FILTER = createAndExpression([ createInExpression(AD_BIDS_PUBLISHER_DIMENSION, RandomPublishers), createInExpression(AD_BIDS_DOMAIN_DIMENSION, RandomDomains), ]); -export const AD_BIDS_APPLY_LARGE_FILTERS: TestDashboardMutation = (mut) => { - mut.dashboard.whereFilter = AD_BIDS_LARGE_FILTER; +export const AD_BIDS_APPLY_LARGE_FILTERS: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.setExprForMetricsView( + filterManager.metricsViewsProvider.metricsViewNames[0], + AD_BIDS_LARGE_FILTER, + ); }; -export const AD_BIDS_APPLY_PUBLISHER_INLIST_FILTER: TestDashboardMutation = ( - mut, -) => { - applyDimensionInListMode(mut, AD_BIDS_PUBLISHER_DIMENSION, [ - "Facebook", - "Google", - ]); +export const AD_BIDS_APPLY_PUBLISHER_INLIST_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.dimensionFilterAction(AD_BIDS_PUBLISHER_DIMENSION, (manager) => + manager.setInList(["Facebook", "Google"], false), + ); }; -export const AD_BIDS_APPLY_DOMAIN_CONTAINS_FILTER: TestDashboardMutation = ( - mut, -) => { - applyDimensionContainsMode(mut, AD_BIDS_DOMAIN_DIMENSION, "%oo%"); +export const AD_BIDS_APPLY_DOMAIN_CONTAINS_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + // setContainsText wraps the text in `%`, so the extra pair here keeps the `%%oo%%` of the + // equivalent dashboard action. + filterManager.dimensionFilterAction(AD_BIDS_DOMAIN_DIMENSION, (manager) => + manager.setContainsText("%oo%", false), + ); }; export const AD_BIDS_APPLY_IMP_MEASURE_FILTER: TestDashboardMutation = (mut) => @@ -106,7 +139,7 @@ export const AD_BIDS_APPLY_IMP_MEASURE_FILTER: TestDashboardMutation = (mut) => }); export const AD_BIDS_APPLY_IMP_COUNTRY_BETWEEN_MEASURE_FILTER: TestDashboardMutation = (mut) => - setMeasureFilter(mut, AD_BIDS_COUNTRY_DIMENSION, { + appendMeasureFilterToParam(mut, AD_BIDS_COUNTRY_DIMENSION, { measure: AD_BIDS_BID_PRICE_MEASURE, type: MeasureFilterType.Value, operation: MeasureFilterOperation.Between, @@ -115,7 +148,7 @@ export const AD_BIDS_APPLY_IMP_COUNTRY_BETWEEN_MEASURE_FILTER: TestDashboardMuta }); export const AD_BIDS_APPLY_IMP_COUNTRY_NOT_BETWEEN_MEASURE_FILTER: TestDashboardMutation = (mut) => - setMeasureFilter(mut, AD_BIDS_COUNTRY_DIMENSION, { + appendMeasureFilterToParam(mut, AD_BIDS_COUNTRY_DIMENSION, { measure: AD_BIDS_BID_PRICE_MEASURE, type: MeasureFilterType.Value, operation: MeasureFilterOperation.NotBetween, @@ -523,17 +556,60 @@ export const AD_BIDS_SET_TIME_DIMENSION_PRIMARY: TestDashboardMutation = ( mut.dashboard.selectedTimeDimension = undefined; }; +/** + * Adds a measure filter to the filter param of the 1st metrics view, preserving the filters + * already in it. + * + * TODO: ExpressionFilterManager has no measure equivalent of `dimensionFilterAction`. A measure + * filter only ever reaches the manager through the filter param, so seed it the same way. + */ +function appendMeasureFilterToParam( + { filterManager }: TestDashboardMutables, + dimension: string, + measureFilter: MeasureFilterEntry, +) { + const havingExpr = mapMeasureFilterToExpr(measureFilter); + if (!havingExpr) return; + + const mvName = filterManager.metricsViewsProvider.metricsViewNames[0]; + const exprs = + filterManager.managerByMetricsView[mvName]?.expr?.cond?.exprs ?? []; + const expr = createAndExpression([ + ...exprs, + createSubQueryExpression(dimension, [measureFilter.measure], havingExpr), + ]); + + filterManager.setParamForMetricsView( + mvName, + convertExpressionToFilterParam(expr, filterManager.inList), + ); +} + export async function applyMutationsToDashboard( name: string, mutations: TestDashboardMutation[], + filterManager?: ExpressionFilterManager, ) { for (const mutation of mutations) { updateMetricsExplorerByName(name, (dashboard) => { const dashboardMutables = { dashboard, - } as DashboardMutables; + filterManager, + } as TestDashboardMutables; mutation(dashboardMutables); }); + if (filterManager) { + // Explore state does not own the filters anymore, so mirror them back into it the way + // DashboardStateSync does for the app. + updateMetricsExplorerByName(name, (dashboard) => { + const mvName = filterManager.metricsViewsProvider.metricsViewNames[0]; + setFilter( + { dashboard } as DashboardMutables, + filterManager.managerByMetricsView[mvName]?.expr, + filterManager.inList, + ); + }); + } // DashboardStateSync.gotoNewState that listens to changes to the dashboard store is an async function. // So go through the mutations individually and wait for 1ms for that to finish. // Without this the lock in gotoNewState will stop the very quick successive changes to dashboard. diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index c906a038a327..6f9e121661e5 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -3,7 +3,6 @@ import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import DashboardMetricsDraggableList from "@rilldata/web-common/components/menu/DashboardMetricsDraggableList.svelte"; - import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import ReplacePivotDialog from "@rilldata/web-common/features/dashboards/pivot/ReplacePivotDialog.svelte"; import { splitPivotChips } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; import { @@ -15,7 +14,6 @@ metricsExplorerStore, useExploreState, } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; - 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 ChartTypeSelector from "@rilldata/web-common/features/dashboards/time-dimension-details/charts/ChartTypeSelector.svelte"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; @@ -31,7 +29,10 @@ type AvailableTimeGrain, type DashboardTimeControls, } from "@rilldata/web-common/lib/time/types"; - import { type MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client/gen/index.schemas"; + import { + type MetricsViewSpecMeasure, + type V1Expression, + } from "@rilldata/web-common/runtime-client/gen/index.schemas"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { DateTime, Interval } from "luxon"; import { Button } from "../../../components/button"; @@ -54,6 +55,8 @@ const scrubController = new ScrubController(); export let exploreName: string; + export let dimensionOnlyFilter: V1Expression | undefined; + export let whereFilter: V1Expression | undefined; export let hideStartPivotButton = false; // Height of the expanded chart in the Time Dimension Detail view, controlled // by the resizable divider between the timeseries and the detail table. @@ -95,8 +98,7 @@ aggregationOptions, } = $timeControlsStore); - $: ({ whereFilter, dimensionThresholdFilters, selectedTimezone } = - $dashboardStore); + $: ({ selectedTimezone } = $dashboardStore); // Use the full selected time range for chart data fetching (not modified by scrub) $: chartInterval = @@ -175,10 +177,6 @@ } $: chartMetricsViewName = $metricsViewName; - $: chartWhere = sanitiseExpression( - mergeDimensionAndMeasureFilters(whereFilter, dimensionThresholdFilters), - undefined, - ); $: chartReady = !!ready; @@ -400,7 +398,7 @@ isMeasureExpanded={showTimeDimensionDetail} {showComparison} metricsViewName={chartMetricsViewName} - where={chartWhere} + where={whereFilter} {timeDimension} {timeStart} {timeEnd} @@ -417,7 +415,7 @@ {connectNulls} tddChartType={tddChartType ?? TDDChart.DEFAULT} metricsViewName={chartMetricsViewName} - where={chartWhere} + where={whereFilter} {timeDimension} interval={chartInterval} comparisonInterval={chartComparisonInterval} @@ -484,7 +482,7 @@ metricsViewName={chartMetricsViewName} tddChartType={tddChartType ?? TDDChart.DEFAULT} {expressionFilterManager} - where={chartWhere} + where={whereFilter} {timeDimension} {timeStart} {timeEnd} @@ -496,7 +494,7 @@ timeGranularity={activeTimeGrain} timeZone={selectedTimezone} dimensionValues={chartDimensionValues} - dimensionWhere={whereFilter} + dimensionWhere={dimensionOnlyFilter} {showComparison} {showTimeDimensionDetail} dynamicYAxis={dynamicYAxisScale} 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..d98d702dca98 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[]; @@ -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 f0f5ff68a0fb..d136fe7c954e 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -38,7 +38,6 @@ exploreTimeseriesWidth, tddChartHeight, } from "./dashboard-layout-store"; - import { createAndExpression } from "../stores/filter-utils"; export let exploreName: string; export let metricsViewName: string; @@ -70,10 +69,8 @@ const client = useRuntimeClient(); $: ({ selectedTimeDimension } = $dashboardStore); - $: whereFilter = derived( - expressionFilterManager.exprByMetricsViewStore, - (exprByMetricsViewStore) => Object.values(exprByMetricsViewStore)[0], - ); + const filterStore = + expressionFilterManager.getExprStoreForMetricsView(metricsViewName); $: extraLeftPadding = !$navigationOpen; @@ -211,6 +208,8 @@ {#if hasTimeSeries} @@ -262,7 +261,8 @@ Date: Tue, 11 Aug 2026 11:44:54 +0530 Subject: [PATCH 17/21] Remove usages of dimensionThresholdFilters --- .../alerts/metadata/AlertFilters.svelte | 131 ++++-------- .../features/alerts/AlertPreviewTable.svelte | 1 - .../src/features/alerts/create-alert-utils.ts | 1 - .../canvas/components/BaseCanvasComponent.ts | 24 ++- .../canvas/components/charts/BaseChart.ts | 7 +- .../components/charts/custom-chart/index.ts | 8 +- .../components/charts/variants/ComboChart.ts | 7 +- .../leaderboard/LeaderboardDisplay.svelte | 10 +- .../components/charts/ChartContainer.svelte | 189 ++++++++++-------- .../big-number/MeasuresContainer.svelte | 13 +- .../dimension-table/dimension-table-export.ts | 1 - .../filters/ExpressionFilterManager.spec.ts | 5 +- .../filters/ExpressionFilterManager.svelte.ts | 154 ++++++++------ .../filters/FilterChipsReadOnly.svelte | 128 ------------ .../MetricsViewFilterManager.svelte.ts | 33 ++- .../DimensionFilterManager.svelte.ts | 1 + .../dimension-filter-values.ts | 21 +- .../measure-filters/measure-filter-utils.ts | 72 ------- .../dashboards/pivot/pivot-data-config.ts | 6 +- .../features/dashboards/pivot/pivot-export.ts | 9 +- .../dashboards/proto-state/fromProto.ts | 41 ++-- .../dashboards/proto-state/toProto.ts | 37 ++-- .../rows-viewer/RowsViewerAccordion.svelte | 16 +- .../state-managers/actions/filters.ts | 54 +---- .../state-managers/actions/measure-filters.ts | 73 +------ .../selectors/measure-filters.ts | 128 +----------- .../stores/AdvancedMeasureCorrector.ts | 23 ++- .../dashboards/stores/filter-utils.ts | 35 ---- .../stores/test-data/store-mutations.ts | 139 +++++-------- .../time-controls/time-control-store.ts | 1 + .../time-dimension-details/tdd-export.ts | 1 - .../time-series/multiple-dimension-queries.ts | 12 +- .../time-series/timeseries-data-store.ts | 11 +- .../time-series/totals-data-store.ts | 17 +- ...ert-partial-explore-state-to-url-params.ts | 9 +- .../url-state/convertPresetToExploreState.ts | 8 +- .../url-state/url-state-variations.spec.ts | 4 +- .../dashboards/workspace/Dashboard.svelte | 20 +- .../get-dashboard-from-aggregation-request.ts | 55 ++--- .../explore-link/explore-state-transformer.ts | 7 +- .../features/exports/export-filters.spec.ts | 3 - .../src/features/exports/export-filters.ts | 10 +- 42 files changed, 462 insertions(+), 1063 deletions(-) delete mode 100644 web-common/src/features/dashboards/filters/FilterChipsReadOnly.svelte delete mode 100644 web-common/src/features/dashboards/filters/measure-filters/measure-filter-utils.ts diff --git a/web-admin/src/features/alerts/metadata/AlertFilters.svelte b/web-admin/src/features/alerts/metadata/AlertFilters.svelte index a453573f7101..f610b69cc505 100644 --- a/web-admin/src/features/alerts/metadata/AlertFilters.svelte +++ b/web-admin/src/features/alerts/metadata/AlertFilters.svelte @@ -1,109 +1,62 @@
- {m.alert_filters_label({ count: String(filtersLength) })} -
- {#if filtersLength} - {#if hasTimeRange} - - {/if} - {#each currentDimensionFilters as { name, label, mode, selectedValues, inputText, isInclude } (name)} - {@const dimension = dimensionIdMap.get(name)} -
- {#if dimension?.column} - - {/if} -
- {/each} - {#if currentMeasureFilters.length > 0} - {#each currentMeasureFilters as { name, label, dimensionName, filter } (name)} -
- -
- {/each} - {/if} - {:else} -
- {m.alert_no_filters_heading()} -
- {/if} -
+ + {m.alert_filters_label({ count: String(filtersLength) })} + +
diff --git a/web-common/src/features/alerts/AlertPreviewTable.svelte b/web-common/src/features/alerts/AlertPreviewTable.svelte index 87d5bfc13ed4..ee8ee1fdab70 100644 --- a/web-common/src/features/alerts/AlertPreviewTable.svelte +++ b/web-common/src/features/alerts/AlertPreviewTable.svelte @@ -59,7 +59,6 @@ columns, columnWidths, rows, - containerWidth, config, ); } diff --git a/web-common/src/features/alerts/create-alert-utils.ts b/web-common/src/features/alerts/create-alert-utils.ts index 6491e284f9ff..b249faceb152 100644 --- a/web-common/src/features/alerts/create-alert-utils.ts +++ b/web-common/src/features/alerts/create-alert-utils.ts @@ -9,7 +9,6 @@ import { getInitialScheduleFormValues } from "@rilldata/web-common/features/sche import { V1Operation } from "@rilldata/web-common/runtime-client"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils.ts"; 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 { page } from "$app/stores"; diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index 1d6a8185b598..fd1b973ffbba 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -201,7 +201,9 @@ export abstract class BaseCanvasComponent { this.localTimeControls.grainStore, this.localTimeControls.comparisonRangeStore, this.localTimeControls.rangeStore, - this.parent.expressionFilterManager.exprByMetricsViewStore, + this.parent.expressionFilterManager.getExprStoreForMetricsView( + this.metricsViewName, + ), this.parent.timeManager.hasTimeSeriesMap, this.specStore, ], @@ -225,8 +227,6 @@ export abstract class BaseCanvasComponent { ]) => { const hasTimeSeries = hasTimeSeriesMap.get(this.metricsViewName); - const mvFilters = metricsViewFilters[this.metricsViewName]; - let timeGrain = globalGrainStore; let timeRange: V1TimeRange = { @@ -267,7 +267,7 @@ export abstract class BaseCanvasComponent { }, }; - if (!mvFilters) { + if (!metricsViewFilters) { return { timeRange: timeRange, where: undefined, @@ -326,23 +326,29 @@ export abstract class BaseCanvasComponent { } // Dimension Filters - const globalWhere = - sanitiseExpression(mvFilters, undefined) ?? createAndExpression([]); + const globalDimensionOnlyWhere = + sanitiseExpression(metricsViewFilters.dimensionOnlyExpr, undefined) ?? + createAndExpression([]); - let where: V1Expression | undefined = globalWhere; + let dimensionOnlyWhere: V1Expression | undefined = + globalDimensionOnlyWhere; if (componentSpec?.["dimension_filters"]) { const { expr: componentWhere } = getFiltersFromText( componentSpec?.["dimension_filters"] as string, ); - where = mergeFilters(globalWhere, componentWhere); + dimensionOnlyWhere = mergeFilters( + globalDimensionOnlyWhere, + componentWhere, + ); } return { timeRange, showTimeComparison, comparisonTimeRange, - where, + dimensionOnlyWhere, + where: metricsViewFilters.expr, timeGrain, timeRangeState, comparisonTimeRangeState, diff --git a/web-common/src/features/canvas/components/charts/BaseChart.ts b/web-common/src/features/canvas/components/charts/BaseChart.ts index 9338a9bd8a67..9fded3f09dd2 100644 --- a/web-common/src/features/canvas/components/charts/BaseChart.ts +++ b/web-common/src/features/canvas/components/charts/BaseChart.ts @@ -16,7 +16,6 @@ import type { } from "@rilldata/web-common/features/canvas/inspector/types"; import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import { transformChartSpecToPivotState } from "@rilldata/web-common/features/components/charts/explore-transformer"; -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 { DashboardState_ActivePage } from "@rilldata/web-common/proto/gen/rill/ui/v1/dashboard_pb"; @@ -133,9 +132,6 @@ export abstract class BaseChart< getExploreTransformerProperties(): Partial { const spec = get(this.specStore); - const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter( - this.componentFilters, - ); const timeGrain = get(this.timeAndFilterStore)?.timeGrain; const tddLink = getLinkStateForTimeDimensionDetail(spec, this.type); @@ -149,8 +145,7 @@ export abstract class BaseChart< const passComparison = comparisonChartTypes.includes(this.type); return { - whereFilter: dimensionFilters, - dimensionThresholdFilters, + whereFilter: this.componentFilters, ...(passComparison ? {} : { showTimeComparison: false }), activePage: tddLink.canLink ? DashboardState_ActivePage.TIME_DIMENSIONAL_DETAIL diff --git a/web-common/src/features/canvas/components/charts/custom-chart/index.ts b/web-common/src/features/canvas/components/charts/custom-chart/index.ts index bbc9c34b202b..096e16ab3075 100644 --- a/web-common/src/features/canvas/components/charts/custom-chart/index.ts +++ b/web-common/src/features/canvas/components/charts/custom-chart/index.ts @@ -10,7 +10,6 @@ import type { CanvasEntity, ComponentPath, } from "@rilldata/web-common/features/canvas/stores/canvas-entity"; -import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { PivotChipType, type PivotChipData, @@ -115,10 +114,6 @@ export class CustomChartComponent extends BaseCanvasComponent { const fields = get(this.queryFieldsMeta); const timeAndFilter = get(this.timeAndFilterStore); - const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter( - timeAndFilter?.where, - ); - const columns: PivotChipData[] = []; const rows: PivotChipData[] = []; @@ -153,8 +148,7 @@ export class CustomChartComponent extends BaseCanvasComponent { }; return { - whereFilter: dimensionFilters, - dimensionThresholdFilters, + whereFilter: timeAndFilter?.where, showTimeComparison: false, activePage: DashboardState_ActivePage.PIVOT, pivot, diff --git a/web-common/src/features/canvas/components/charts/variants/ComboChart.ts b/web-common/src/features/canvas/components/charts/variants/ComboChart.ts index 25a74e8426ff..1afe7d59eb55 100644 --- a/web-common/src/features/canvas/components/charts/variants/ComboChart.ts +++ b/web-common/src/features/canvas/components/charts/variants/ComboChart.ts @@ -9,7 +9,6 @@ import { type ChartFieldsMap, type FieldConfig, } from "@rilldata/web-common/features/components/charts/types"; -import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { PivotChipType, type PivotChipData, @@ -282,9 +281,6 @@ export class ComboChartComponent extends BaseChart { override getExploreTransformerProperties(): Partial { const spec = get(this.specStore); - const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter( - this.componentFilters, - ); const timeGrain = get(this.timeAndFilterStore)?.timeGrain; const columns: PivotChipData[] = []; @@ -348,8 +344,7 @@ export class ComboChartComponent extends BaseChart { }; return { - whereFilter: dimensionFilters, - dimensionThresholdFilters, + whereFilter: this.componentFilters, showTimeComparison: false, activePage: DashboardState_ActivePage.PIVOT, pivot, diff --git a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte index 1afdf3c6550f..16ed4fea0137 100644 --- a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte @@ -3,7 +3,6 @@ import { validateLeaderboardSchema } from "@rilldata/web-common/features/canvas/components/leaderboard/selector"; import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import ComponentError from "@rilldata/web-common/features/components/ComponentError.svelte"; - import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { COMPARISON_COLUMN_WIDTH, valueColumn, @@ -58,11 +57,8 @@ let comparisonTimeRange = $derived($timeAndFilterStore.comparisonTimeRange); let timeRange = $derived($timeAndFilterStore.timeRange); - let splitFilters = $derived(splitWhereFilter($timeAndFilterStore.where)); - let whereFilter = $derived(splitFilters.dimensionFilters); - let dimensionThresholdFilters = $derived( - splitFilters.dimensionThresholdFilters, - ); + let whereFilter = $derived($timeAndFilterStore.where); + let dimensionOnlyFilter = $derived($timeAndFilterStore.dimensionOnlyWhere); let allDimensions = $derived( metricsViewSelectors.getDimensionsForMetricView(metricsViewName), @@ -185,8 +181,8 @@ leaderboardSortByMeasureName={leaderboardSortByMeasureName ?? leaderboardMeasureNames[0]} leaderboardMeasures={visibleMeasures} + {dimensionOnlyFilter} {whereFilter} - {dimensionThresholdFilters} tableWidth={dimensionColumnWidth + totalContextWidth} {dimensionColumnWidth} sortedAscending={sortDirection === SortDirection.ASCENDING} diff --git a/web-common/src/features/components/charts/ChartContainer.svelte b/web-common/src/features/components/charts/ChartContainer.svelte index 288caff85abe..e61c3e225d0a 100644 --- a/web-common/src/features/components/charts/ChartContainer.svelte +++ b/web-common/src/features/components/charts/ChartContainer.svelte @@ -1,129 +1,147 @@ {#if $spec} @@ -137,13 +155,8 @@

{chartTitle}

{#if "metrics_view" in $spec} - {/if}
- {#if showExploreLink && $exploreAvailability.isAvailable} + {#if showExploreLink && exploreAvailability.isAvailable} {/if} @@ -172,7 +185,7 @@ {chartType} chartSpec={$spec} {chartData} - measures={$measures} + measures={metricsViewProvider.measures} {themeMode} theme={currentTheme} isCanvas={true} diff --git a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte index ec9c91749532..27d4951d5042 100644 --- a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte +++ b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte @@ -1,15 +1,15 @@ - -
- {#if displayTimeRange} - - {/if} - {#if dimensionFilters.length > 0} - {#each dimensionFilters as filterData (filterData.name)} - {@const dimension = filterData.dimensions.get(metricsViewNames[0])} - - {#if dimension} - - {/if} - - {/each} - {/if} - {#if measureFilters.length > 0} - {#each measureFilters as { name, label, dimensionName, filter } (name)} -
- -
- {/each} - {/if} -
- - diff --git a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts index 8eeec0e648e6..137653cb9ecd 100644 --- a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts @@ -53,7 +53,7 @@ export class MetricsViewFilterManager { ) { this.managers = $state(managers); - this.expr = $derived(this.buildExpression()); + this.expr = $derived(this.buildExpression(this.managers)); this.dimensionOnlyExpr = $derived(this.buildDimensionOnlyExpression()); this.param = $derived( this.expr ? convertExpressionToFilterParam(this.expr, this.inList) : "", @@ -172,9 +172,13 @@ export class MetricsViewFilterManager { !this.metricsViewsProvider.dimensionSpecs[dimensionName]?.[ this.metricsViewName ]; - if (!dimensionFilterManager.expr || alreadyAdded || dimensionNotInMV) - return; + if (alreadyAdded || dimensionNotInMV) return; + console.log( + "maybeAddDimensionFilter:added", + this.metricsViewName, + dimensionName, + ); this.managers = { ...this.managers, dimensionManagers: [ @@ -184,14 +188,29 @@ export class MetricsViewFilterManager { }; } - private buildExpression() { - const dimensionExprs = this.managers.dimensionManagers + public maybeAddMeasureFilter(measureFilterManager: MeasureFilterManager) { + const measureName = measureFilterManager.name; + const alreadyAdded = !!this.managerLookup[measureName]; + const measureNotInMV = + !this.metricsViewsProvider.measureSpecs[measureName]?.[ + this.metricsViewName + ]; + if (alreadyAdded || measureNotInMV) return; + + this.managers = { + ...this.managers, + measureManagers: [...this.managers.measureManagers, measureFilterManager], + }; + } + + private buildExpression(managers: AllManagers) { + const dimensionExprs = managers.dimensionManagers .map((dfm) => dfm.expr) .filter(Boolean) as V1Expression[]; - const measureExprs = this.managers.measureManagers + const measureExprs = managers.measureManagers .map((mfm) => mfm.expr) .filter(Boolean) as V1Expression[]; - const joinerExprs = this.managers.joinerManagers + const joinerExprs = managers.joinerManagers .map((jm) => jm.expr) .filter(Boolean) as V1Expression[]; const exprs = dimensionExprs.concat(measureExprs).concat(joinerExprs); diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts index 113cd2faecda..56d6cb1bb396 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts @@ -190,6 +190,7 @@ export class DimensionFilterManager { this.mode === DimensionFilterMode.InList ? [this.name] : [], ) : ""; + console.log("committing", this.param); } } 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/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/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/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/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/state-managers/actions/filters.ts b/web-common/src/features/dashboards/state-managers/actions/filters.ts index 8071a8310487..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,53 +1 @@ -import type { DashboardMutables } from "@rilldata/web-common/features/dashboards/state-managers/actions/types"; -import { - createAndExpression, - filterExpressions, -} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import type { V1Expression } from "@rilldata/web-common/runtime-client"; -import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils.ts"; - -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 function setFilter( - { dashboard }: DashboardMutables, - expr: V1Expression | undefined, - inList: string[], -) { - const { dimensionFilters, dimensionThresholdFilters } = splitWhereFilter( - expr ? filterExpressions(expr, () => true) : undefined, - ); - dashboard.whereFilter = dimensionFilters; - dashboard.dimensionThresholdFilters = dimensionThresholdFilters; - dashboard.dimensionsWithInlistFilter = inList; -} - -export const filterActions = { - /** - * Clears all filters and resets related fields - */ - clearAllFilters, - - setTemporaryFilterName, - - setFilter, -}; +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/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/stores/AdvancedMeasureCorrector.ts b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts index f027cc438e4c..f85bfc790ff1 100644 --- a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts +++ b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts @@ -55,17 +55,18 @@ export class AdvancedMeasureCorrector { } 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, - ); + // TODO: correct whereFilter + // 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() { diff --git a/web-common/src/features/dashboards/stores/filter-utils.ts b/web-common/src/features/dashboards/stores/filter-utils.ts index a89b40b3cf26..273dd8e46a78 100644 --- a/web-common/src/features/dashboards/stores/filter-utils.ts +++ b/web-common/src/features/dashboards/stores/filter-utils.ts @@ -1,8 +1,4 @@ -import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; -import type { DimensionThresholdFilter } from "@rilldata/web-common/features/dashboards/stores/explore-state"; import { - type MetricsViewSpecDimension, - type MetricsViewSpecMeasure, type V1Condition, type V1Expression, V1Operation, @@ -458,37 +454,6 @@ export function isSubqueryExpressionUnsupported(subquery: V1Subquery) { ); } -export function buildValidMetricsViewFilter( - filter: V1Expression, - dtf: DimensionThresholdFilter[], - dimensions: MetricsViewSpecDimension[], - measures: MetricsViewSpecMeasure[], -) { - const whereFilter = - filterIdentifiers(filter, (e, ident) => { - const dim = dimensions?.find((d) => d.name === ident); - // ignore if dimension is not present anymore - if (!dim) return false; - return true; - }) ?? createAndExpression([]); - - const dimensionThresholdFilter = dtf.filter((f) => { - const dim = dimensions?.find((d) => d.name === f.name); - if (!dim) return false; - - const hasValidMeasures = f.filters.every((filter) => { - const measure = measures?.find((m) => m.name === filter.measure); - return !!measure; - }); - return hasValidMeasures; - }); - - return sanitiseExpression( - mergeDimensionAndMeasureFilters(whereFilter, dimensionThresholdFilter), - undefined, - ); -} - export function wrapNonJoinerExpression(expr: V1Expression): V1Expression { if (isAndOrExpression(expr)) return expr; return createAndExpression([expr]); diff --git a/web-common/src/features/dashboards/stores/test-data/store-mutations.ts b/web-common/src/features/dashboards/stores/test-data/store-mutations.ts index 53a14c9d8b03..554100842bea 100644 --- a/web-common/src/features/dashboards/stores/test-data/store-mutations.ts +++ b/web-common/src/features/dashboards/stores/test-data/store-mutations.ts @@ -1,8 +1,4 @@ import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -import { - mapMeasureFilterToExpr, - type MeasureFilterEntry, -} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; import { MeasureFilterOperation, MeasureFilterType, @@ -13,14 +9,6 @@ import { setPrimaryDimension, toggleDimensionVisibility, } from "@rilldata/web-common/features/dashboards/state-managers/actions/dimensions"; -import { - clearAllFilters, - setFilter, -} from "@rilldata/web-common/features/dashboards/state-managers/actions/filters"; -import { - removeMeasureFilter, - setMeasureFilter, -} from "@rilldata/web-common/features/dashboards/state-managers/actions/measure-filters"; import { toggleMeasureVisibility } from "@rilldata/web-common/features/dashboards/state-managers/actions/measures"; import { setSortDescending, @@ -34,7 +22,6 @@ import { import { createAndExpression, createInExpression, - createSubQueryExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { AD_BIDS_BID_PRICE_MEASURE, @@ -51,7 +38,6 @@ import { RandomPublishers, } from "@rilldata/web-common/features/dashboards/stores/test-data/random"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; -import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters"; import { type DashboardTimeControls, type TimeRange, @@ -129,49 +115,63 @@ export const AD_BIDS_APPLY_DOMAIN_CONTAINS_FILTER: TestDashboardMutation = ({ ); }; -export const AD_BIDS_APPLY_IMP_MEASURE_FILTER: TestDashboardMutation = (mut) => - setMeasureFilter(mut, AD_BIDS_PUBLISHER_DIMENSION, { - measure: AD_BIDS_IMPRESSIONS_MEASURE, - type: MeasureFilterType.Value, - operation: MeasureFilterOperation.GreaterThan, - value1: "10", - value2: "", - }); -export const AD_BIDS_APPLY_IMP_COUNTRY_BETWEEN_MEASURE_FILTER: TestDashboardMutation = - (mut) => - appendMeasureFilterToParam(mut, AD_BIDS_COUNTRY_DIMENSION, { - measure: AD_BIDS_BID_PRICE_MEASURE, +export const AD_BIDS_APPLY_IMP_MEASURE_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.measureFilterAction(AD_BIDS_IMPRESSIONS_MEASURE, (manager) => + manager.setMeasureFilter(AD_BIDS_PUBLISHER_DIMENSION, { + measure: AD_BIDS_IMPRESSIONS_MEASURE, type: MeasureFilterType.Value, - operation: MeasureFilterOperation.Between, + operation: MeasureFilterOperation.GreaterThan, value1: "10", - value2: "20", - }); + value2: "", + }), + ); +}; +export const AD_BIDS_APPLY_IMP_COUNTRY_BETWEEN_MEASURE_FILTER: TestDashboardMutation = + ({ filterManager }) => { + filterManager.measureFilterAction(AD_BIDS_BID_PRICE_MEASURE, (manager) => + manager.setMeasureFilter(AD_BIDS_COUNTRY_DIMENSION, { + measure: AD_BIDS_BID_PRICE_MEASURE, + type: MeasureFilterType.Value, + operation: MeasureFilterOperation.Between, + value1: "10", + value2: "20", + }), + ); + }; export const AD_BIDS_APPLY_IMP_COUNTRY_NOT_BETWEEN_MEASURE_FILTER: TestDashboardMutation = - (mut) => - appendMeasureFilterToParam(mut, AD_BIDS_COUNTRY_DIMENSION, { + ({ filterManager }) => { + filterManager.measureFilterAction(AD_BIDS_BID_PRICE_MEASURE, (manager) => + manager.setMeasureFilter(AD_BIDS_COUNTRY_DIMENSION, { + measure: AD_BIDS_BID_PRICE_MEASURE, + type: MeasureFilterType.Value, + operation: MeasureFilterOperation.NotBetween, + value1: "10", + value2: "20", + }), + ); + }; +export const AD_BIDS_REMOVE_IMP_MEASURE_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.measureFilterAction(AD_BIDS_IMPRESSIONS_MEASURE, (manager) => + manager.clear(), + ); +}; +export const AD_BIDS_APPLY_BP_MEASURE_FILTER: TestDashboardMutation = ({ + filterManager, +}) => { + filterManager.measureFilterAction(AD_BIDS_BID_PRICE_MEASURE, (manager) => + manager.setMeasureFilter(AD_BIDS_DOMAIN_DIMENSION, { measure: AD_BIDS_BID_PRICE_MEASURE, type: MeasureFilterType.Value, - operation: MeasureFilterOperation.NotBetween, + operation: MeasureFilterOperation.GreaterThan, value1: "10", - value2: "20", - }); -export const AD_BIDS_REMOVE_IMP_MEASURE_FILTER: TestDashboardMutation = (mut) => - removeMeasureFilter( - mut, - AD_BIDS_PUBLISHER_DIMENSION, - AD_BIDS_IMPRESSIONS_MEASURE, + value2: "", + }), ); -export const AD_BIDS_APPLY_BP_MEASURE_FILTER: TestDashboardMutation = (mut) => - setMeasureFilter(mut, AD_BIDS_DOMAIN_DIMENSION, { - measure: AD_BIDS_BID_PRICE_MEASURE, - type: MeasureFilterType.Value, - operation: MeasureFilterOperation.GreaterThan, - value1: "10", - value2: "", - }); - -export const AD_BIDS_CLEAR_FILTERS: TestDashboardMutation = (mut) => - clearAllFilters(mut); +}; export const AD_BIDS_SET_P7D_TIME_RANGE_FILTER: TestDashboardMutation = () => metricsExplorerStore.selectTimeRange( @@ -556,35 +556,6 @@ export const AD_BIDS_SET_TIME_DIMENSION_PRIMARY: TestDashboardMutation = ( mut.dashboard.selectedTimeDimension = undefined; }; -/** - * Adds a measure filter to the filter param of the 1st metrics view, preserving the filters - * already in it. - * - * TODO: ExpressionFilterManager has no measure equivalent of `dimensionFilterAction`. A measure - * filter only ever reaches the manager through the filter param, so seed it the same way. - */ -function appendMeasureFilterToParam( - { filterManager }: TestDashboardMutables, - dimension: string, - measureFilter: MeasureFilterEntry, -) { - const havingExpr = mapMeasureFilterToExpr(measureFilter); - if (!havingExpr) return; - - const mvName = filterManager.metricsViewsProvider.metricsViewNames[0]; - const exprs = - filterManager.managerByMetricsView[mvName]?.expr?.cond?.exprs ?? []; - const expr = createAndExpression([ - ...exprs, - createSubQueryExpression(dimension, [measureFilter.measure], havingExpr), - ]); - - filterManager.setParamForMetricsView( - mvName, - convertExpressionToFilterParam(expr, filterManager.inList), - ); -} - export async function applyMutationsToDashboard( name: string, mutations: TestDashboardMutation[], @@ -603,11 +574,11 @@ export async function applyMutationsToDashboard( // DashboardStateSync does for the app. updateMetricsExplorerByName(name, (dashboard) => { const mvName = filterManager.metricsViewsProvider.metricsViewNames[0]; - setFilter( - { dashboard } as DashboardMutables, - filterManager.managerByMetricsView[mvName]?.expr, - filterManager.inList, - ); + dashboard.whereFilter = + filterManager.managerByMetricsView[mvName]?.expr ?? + createAndExpression([]); + dashboard.dimensionThresholdFilters = []; + dashboard.dimensionsWithInlistFilter = filterManager.inList; }); } // DashboardStateSync.gotoNewState that listens to changes to the dashboard store is an async function. diff --git a/web-common/src/features/dashboards/time-controls/time-control-store.ts b/web-common/src/features/dashboards/time-controls/time-control-store.ts index e9e39ca53629..131f8b0edfdf 100644 --- a/web-common/src/features/dashboards/time-controls/time-control-store.ts +++ b/web-common/src/features/dashboards/time-controls/time-control-store.ts @@ -82,6 +82,7 @@ export type ComparisonTimeRangeState = { export interface TimeAndFilterStore { timeRange: V1TimeRange; comparisonTimeRange: V1TimeRange | undefined; + dimensionOnlyWhere?: V1Expression | undefined; where: V1Expression | undefined; timeGrain: V1TimeGrain | undefined; showTimeComparison: boolean; diff --git a/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts b/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts index 0a3404497bae..bbc379d353f0 100644 --- a/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts +++ b/web-common/src/features/dashboards/time-dimension-details/tdd-export.ts @@ -117,7 +117,6 @@ export function getTDDAggregationRequest({ ], where: buildWhereParamForDimensionTableAndTDDExports( exploreState.whereFilter, - exploreState.dimensionThresholdFilters, dimensionName, dimensionSearchText, ), 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..daf92ea11de0 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, @@ -149,13 +148,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, ), 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..6daa9fe8366e 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,4 +1,3 @@ -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, @@ -25,13 +24,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,13 +54,7 @@ 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([]), 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/url-state-variations.spec.ts b/web-common/src/features/dashboards/url-state/url-state-variations.spec.ts index d98d702dca98..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 @@ -123,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", }, { diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index b0be420bbeff..c6b7a0fa8a89 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -72,6 +72,8 @@ $: ({ selectedTimeDimension } = $dashboardStore); const filterStore = expressionFilterManager.getExprStoreForMetricsView(metricsViewName); + $: dimensionOnlyFilter = $filterStore?.dimensionOnlyExpr; + $: whereFilter = $filterStore?.expr; $: extraLeftPadding = !$navigationOpen; @@ -215,13 +217,17 @@ {#if hasTimeSeries} {:else} - + {/if} {/key}
@@ -268,8 +274,8 @@ 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); } From cc9c8e93cd8e9bb243c682c55640b9f9d6e50597 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Tue, 11 Aug 2026 16:32:17 +0530 Subject: [PATCH 18/21] fix incorrect filters being applied --- .../leaderboard/LeaderboardDisplay.svelte | 2 - .../dimension-table/DimensionDisplay.svelte | 13 +- .../dimension-table/DimensionTable.svelte | 234 +++++++++--------- .../dimension-table/dimension-table-utils.ts | 5 +- .../filters/ExpressionFilterManager.spec.ts | 4 +- .../filters/ExpressionFilterManager.svelte.ts | 4 +- .../filters/ExpressionFilters.svelte | 4 +- .../MetricsViewFilterManager.svelte.ts | 4 +- .../filters/ReadonlyExpressionFilters.svelte | 4 +- .../filters/VerticalExpressionFilters.svelte | 4 +- .../AdvancedFilterPill.svelte | 8 +- .../DimensionFilter.svelte | 30 +-- .../DimensionFilterManager.svelte.ts | 0 .../ReadonlyDimensionFilter.svelte | 4 +- .../queries.svelte.ts | 0 .../MeasureFilter.svelte | 20 +- .../MeasureFilterManager.svelte.ts | 0 .../ReadonlyMeasureFilter.svelte | 8 +- .../dashboards/leaderboard/Leaderboard.svelte | 7 +- .../leaderboard/LeaderboardDisplay.svelte | 2 - .../selectors/dimension-filters.ts | 9 +- .../dashboards/stores/filter-utils.ts | 2 +- .../MetricsTimeSeriesCharts.svelte | 147 ++++++----- .../time-series/multiple-dimension-queries.ts | 10 +- .../time-series/totals-data-store.ts | 13 +- .../dashboards/workspace/Dashboard.svelte | 2 - 26 files changed, 278 insertions(+), 262 deletions(-) rename web-common/src/features/dashboards/filters/{dimension-filters-v2 => dimension-filters}/DimensionFilter.svelte (92%) rename web-common/src/features/dashboards/filters/{dimension-filters-v2 => dimension-filters}/DimensionFilterManager.svelte.ts (100%) rename web-common/src/features/dashboards/filters/{dimension-filters-v2 => dimension-filters}/ReadonlyDimensionFilter.svelte (97%) rename web-common/src/features/dashboards/filters/{dimension-filters-v2 => dimension-filters}/queries.svelte.ts (100%) rename web-common/src/features/dashboards/filters/{measure-filters-v2 => measure-filters}/MeasureFilter.svelte (81%) rename web-common/src/features/dashboards/filters/{measure-filters-v2 => measure-filters}/MeasureFilterManager.svelte.ts (100%) rename web-common/src/features/dashboards/filters/{measure-filters-v2 => measure-filters}/ReadonlyMeasureFilter.svelte (70%) diff --git a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte index 16ed4fea0137..529d74c429f5 100644 --- a/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/canvas/components/leaderboard/LeaderboardDisplay.svelte @@ -58,7 +58,6 @@ let timeRange = $derived($timeAndFilterStore.timeRange); let whereFilter = $derived($timeAndFilterStore.where); - let dimensionOnlyFilter = $derived($timeAndFilterStore.dimensionOnlyWhere); let allDimensions = $derived( metricsViewSelectors.getDimensionsForMetricView(metricsViewName), @@ -181,7 +180,6 @@ leaderboardSortByMeasureName={leaderboardSortByMeasureName ?? leaderboardMeasureNames[0]} leaderboardMeasures={visibleMeasures} - {dimensionOnlyFilter} {whereFilter} tableWidth={dimensionColumnWidth + totalContextWidth} {dimensionColumnWidth} diff --git a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte index c63705d8a308..2535f7cb924f 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionDisplay.svelte @@ -29,14 +29,12 @@ import DimensionHeader from "./DimensionHeader.svelte"; import DimensionTable from "./DimensionTable.svelte"; import { getDimensionFilterWithSearch } from "./dimension-table-utils"; - import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters.ts"; import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; const queryLimit = 250; export let timeRange: V1TimeRange; export let comparisonTimeRange: V1TimeRange | undefined; - export let dimensionOnlyFilter: V1Expression | undefined; export let whereFilter: V1Expression | undefined; export let metricsViewName: string; export let visibleMeasureNames: string[]; @@ -67,14 +65,14 @@ $: selectedValues = selectedDimensionValues( client, [metricsViewName], - dimensionOnlyFilter, + whereFilter, dimensionName, timeRange.start, timeRange.end, ); $: filterSet = getDimensionFilterWithSearch( - dimensionOnlyFilter, + whereFilter, $dimensionSearchText, dimensionName, ); @@ -118,10 +116,7 @@ (m) => !m.comparisonValue && !m.comparisonDelta && !m.comparisonRatio, ), where: sanitiseExpression( - mergeFilters( - getFiltersForOtherDimensions(dimensionOnlyFilter, dimensionName), - whereFilter, - ), + getFiltersForOtherDimensions(whereFilter, dimensionName), ), timeRange, }, @@ -162,7 +157,7 @@ timeRange, comparisonTimeRange, sort, - where: sanitiseExpression(mergeFilters(filterSet, whereFilter)), + where: sanitiseExpression(filterSet), limit: queryLimit.toString(), offset: "0", }, diff --git a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte index 5fc08a2e2664..03cd945c01b0 100644 --- a/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte +++ b/web-common/src/features/dashboards/dimension-table/DimensionTable.svelte @@ -11,7 +11,7 @@ TableCells – the cell contents. import { selectedDimensionValues } from "@rilldata/web-common/features/dashboards/state-managers/selectors/dimension-filters"; import DelayedSpinner from "@rilldata/web-common/features/entity-management/DelayedSpinner.svelte"; import { createVirtualizer } from "@tanstack/svelte-virtual"; - import { setContext } from "svelte"; + import { setContext, untrack } from "svelte"; import { getStateManagers } from "../state-managers/state-managers"; import type { DimensionTableRow } from "./dimension-table-types"; import { @@ -67,9 +67,7 @@ TableCells – the cell contents. ); let isBeingCompared = $derived($isBeingComparedReadable(dimensionName)); - let container: HTMLDivElement; - - let containerWidth: number = $state(0); + let container: HTMLDivElement | undefined = $state(); /** this is a perceived character width value, in pixels, when our monospace * font is 12px high. */ @@ -82,9 +80,6 @@ TableCells – the cell contents. }) ?? [], ); - let rowScrollOffset = $state(0); - let colScrollOffset = $state(0); - /** if we're inferring the column widths from static-ish data, let's * find the largest strings in the column and use that to bootstrap the * column widths. @@ -117,25 +112,42 @@ TableCells – the cell contents. let manualDimensionColumnWidth: number | null = $state(null); - let rowVirtualizer = $derived( - createVirtualizer({ - getScrollElement: () => container, - count: rows.length, - estimateSize: () => config.rowHeight, - // Provides a stable identity for each virtualized row so the virtualizer can reuse DOM nodes - // instead of remounting them during fast scrolls or data updates. This reduces blank frames, - // preserves focus/hover state, and avoids unnecessary re-renders. - getItemKey: (index) => String(rows?.[index]?.[dimensionName] ?? index), - overscan: rowOverscanAmount, - paddingStart: config.columnHeaderHeight, - initialOffset: rowScrollOffset, - }), - ); - $effect(() => { - rowScrollOffset = $rowVirtualizer?.scrollOffset || 0; + /** The virtualizers are created once and updated through `setOptions`; they must not be + * recreated per data change. A virtualizer only attaches its scroll and resize observers inside + * `setOptions`, which the store runs a single time, when it gains its first subscriber. That + * first subscription happens while rendering the template, before `bind:this` has assigned + * `container`, so an instance that is never handed fresh options stays detached, reports a + * scroll size of 0, and yields an empty set of virtual items. + * + * Only static options belong in the constructor. Everything derived from reactive state is + * applied by the effect below, which is also what attaches the virtualizer once `container` + * exists. + */ + const rowVirtualizer = createVirtualizer({ + getScrollElement: () => container ?? null, + estimateSize: () => config.rowHeight, + paddingStart: config.columnHeaderHeight, + count: 0, }); + $effect(() => { - colScrollOffset = $columnVirtualizer?.scrollOffset || 0; + if (!container) return; + + const options = { + count: rows.length, + overscan: rowOverscanAmount, + // This has to be a fresh closure rather than a stable one. `getItemKey` is part of the + // virtualizer's measurement cache key, and the keys it returns are what the row `{#each}` + // blocks are keyed by, so reusing one function would leave stale keys behind after a + // re-sort. Keying rows by their dimension value lets the virtualizer reuse DOM nodes during + // fast scrolls instead of remounting them, which avoids blank frames and preserves focus. + getItemKey: (index: number) => + String(rows?.[index]?.[dimensionName] ?? index), + }; + + // `setOptions` pushes a new value into the store, so the virtualizer must be read untracked + // to keep this effect from retriggering itself. + untrack(() => $rowVirtualizer.setOptions(options)); }); $effect(() => { @@ -153,26 +165,37 @@ TableCells – the cell contents. } }); - let columnVirtualizer = $derived( - createVirtualizer({ - getScrollElement: () => container, - horizontal: true, + const columnVirtualizer = createVirtualizer({ + getScrollElement: () => container ?? null, + horizontal: true, + estimateSize: (index) => estimateColumnSize[index + 1], + count: 0, + }); + + $effect(() => { + if (!container) return; + + const options = { count: measureColumns.length, - getItemKey: (index) => measureColumns[index].name, - estimateSize: (index) => { - return estimateColumnSize[index + 1]; - }, overscan: columnOverscanAmount, - paddingStart: estimateColumnSize[0] + FILTER_COLUMN_WIDTH, - initialOffset: colScrollOffset, - }), - ); + getItemKey: (index: number) => measureColumns[index].name, + estimateSize: (index: number) => estimateColumnSize[index + 1], + paddingStart: (estimateColumnSize[0] ?? 0) + FILTER_COLUMN_WIDTH, + }; - let virtualRows = $derived($rowVirtualizer?.getVirtualItems() ?? []); - let virtualHeight = $derived($rowVirtualizer?.getTotalSize() ?? 0); + untrack(() => { + $columnVirtualizer.setOptions(options); + // `estimateSize` is not part of the measurement cache key, so changed column widths only + // take effect once the cached item sizes are dropped. + $columnVirtualizer.measure(); + }); + }); - let virtualColumns = $derived($columnVirtualizer?.getVirtualItems() ?? []); - let virtualWidth = $derived($columnVirtualizer?.getTotalSize() ?? 0); + let virtualRows = $derived($rowVirtualizer.getVirtualItems()); + let virtualHeight = $derived($rowVirtualizer.getTotalSize()); + + let virtualColumns = $derived($columnVirtualizer.getVirtualItems()); + let virtualWidth = $derived($columnVirtualizer.getTotalSize()); let activeIndex: number = $state(-1); function setActiveIndex(index: number) { @@ -194,29 +217,12 @@ TableCells – the cell contents. } }); - function onSelectItemHandler(data: { index: number; meta: boolean }) { - // store previous scroll position before re-render - rowScrollOffset = $rowVirtualizer?.scrollOffset ?? 0; - colScrollOffset = $columnVirtualizer?.scrollOffset ?? 0; - onSelectItem(data); - } - - function handleColumnHeaderClick(columnName: string) { - colScrollOffset = $columnVirtualizer?.scrollOffset ?? 0; - - dimensionTable.handleDimensionMeasureColumnHeaderClick(columnName); - } - function handleResizeDimensionColumn(size: number) { - rowScrollOffset = $rowVirtualizer?.scrollOffset ?? 0; - colScrollOffset = $columnVirtualizer?.scrollOffset ?? 0; - manualDimensionColumnWidth = Math.max(config.minColumnWidth, size); }
{ - horizontalScrolling = container?.scrollLeft > 0; + horizontalScrolling = (container?.scrollLeft ?? 0) > 0; /** capture to suppress cell tooltips. Otherwise, * there's quite a bit of rendering jank. */ @@ -236,74 +242,72 @@ TableCells – the cell contents. class="overflow-auto grid max-w-fit" style:grid-template-columns="max-content auto" > - {#if $rowVirtualizer} -
- - + + + +
+ + - -
- - - {#if dimensionColumn} - - {/if} -
- {#if rows.length} - - {/if}
- {/if} + {#if rows.length} + + + {/if} +
{#if !rows.length} - {#if hasClearableFilters} - + + {#if hasClearableFilters} + + {/if} {/if}
diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 08c74913a5d6..757e4f6f0134 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -40,7 +40,6 @@ import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import ExpressionFilters from "./ExpressionFilters.svelte"; import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; - import { recordsMatch } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; const { rillTime } = featureFlags; diff --git a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts index de78ac6d3edf..bbefa88965bf 100644 --- a/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/MetricsViewFilterManager.svelte.ts @@ -5,15 +5,12 @@ import { import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts"; import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts"; import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; -import { - getDimensionDisplayName, - getMeasureDisplayName, -} from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; import { createAndExpression, createOrExpression, } 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 { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; export type DimensionOrMeasureManager = | DimensionFilterManager @@ -78,12 +75,46 @@ export class MetricsViewFilterManager { expr: V1Expression | undefined, dimensionsWithInlistFilter: string[], existingManagers: Map, + yamlConfigProvider: YAMLConfigProvider | undefined, ): AnyManager | undefined { const op = expr?.cond?.op; if (op === V1Operation.OPERATION_AND || op === V1Operation.OPERATION_OR) { const dimensionManagers: DimensionFilterManager[] = []; const measureManagers: MeasureFilterManager[] = []; const joinerManagers: MetricsViewFilterManager[] = []; + const added = new Set(); + + const add = (manager: AnyManager, force: boolean) => { + if (manager.param && !existingManagers.has(manager.param)) { + manager = existingManagers.get(manager.param) ?? manager; + existingManagers.set(manager.param, manager); + } else if ( + !manager.param && + !(manager instanceof MetricsViewFilterManager) + ) { + manager = existingManagers.get(manager.name) ?? manager; + existingManagers.set( + (manager as DimensionOrMeasureManager).name, + manager, + ); + } + + if ( + manager instanceof DimensionFilterManager && + (!added.has(manager.name) || force) + ) { + added.add(manager.name); + dimensionManagers.push(manager); + } else if ( + manager instanceof MeasureFilterManager && + (!added.has(manager.name) || force) + ) { + added.add(manager.name); + measureManagers.push(manager); + } else if (manager instanceof MetricsViewFilterManager) { + joinerManagers.push(manager); + } + }; expr?.cond?.exprs?.forEach((e) => { let manager = MetricsViewFilterManager.parse( @@ -92,27 +123,39 @@ export class MetricsViewFilterManager { e, dimensionsWithInlistFilter, existingManagers, + undefined, ); if (!manager) return; - // Prefer to share class for similar expressions. // This is a hack to make a single filter across the metrics view work. // TODO: find a better solution to share - if (existingManagers.has(manager.param)) { - manager = existingManagers.get(manager.param)!; - } else { - existingManagers.set(manager.param, manager); - } + manager = existingManagers.get(manager.param) ?? manager; - if (manager instanceof DimensionFilterManager) { - dimensionManagers.push(manager); - } else if (manager instanceof MeasureFilterManager) { - measureManagers.push(manager); - } else if (manager instanceof MetricsViewFilterManager) { - joinerManagers.push(manager); - } + add(manager, true); }); + // Add required and pinned filters when yamlConfigProvider is provided. + // yamlConfigProvider is only provided for the top level manager. + // TODO: handle advanced filters + if (yamlConfigProvider) { + Object.keys(yamlConfigProvider.requiredFilters) + .concat(Object.keys(yamlConfigProvider.pinnedFilters)) + .forEach((requiredFilter) => { + const manager = + DimensionFilterManager.createForMetricsViews( + metricsViewsProvider, + requiredFilter, + metricsViewName, + ) ?? + MeasureFilterManager.createForMetricsViews( + metricsViewsProvider, + requiredFilter, + metricsViewName, + ); + if (manager) add(manager, false); + }); + } + return new MetricsViewFilterManager( metricsViewName, metricsViewsProvider, @@ -138,24 +181,18 @@ export class MetricsViewFilterManager { // Having a subquery means this is a measure filter. const measureName = firstValueExpr.subquery.measures?.[0]; if (!measureName) return undefined; - const measureSpec = - metricsViewsProvider.measureSpecs[measureName]?.[metricsViewName]; - if (!measureSpec) return undefined; - - return new MeasureFilterManager( + return MeasureFilterManager.createForMetricsViews( + metricsViewsProvider, measureName, - getMeasureDisplayName(measureSpec), + metricsViewName, firstValueExpr, ); } else { // Everything else is a dimension filter for now. - const dimensionSpec = - metricsViewsProvider.dimensionSpecs[ident]?.[metricsViewName]; - if (!dimensionSpec) return undefined; - - return new DimensionFilterManager( + return DimensionFilterManager.createForMetricsViews( + metricsViewsProvider, ident, - getDimensionDisplayName(dimensionSpec), + metricsViewName, expr, dimensionsWithInlistFilter.includes(ident), ); @@ -174,11 +211,6 @@ export class MetricsViewFilterManager { ]; if (alreadyAdded || dimensionNotInMV) return; - console.log( - "maybeAddDimensionFilter:added", - this.metricsViewName, - dimensionName, - ); this.managers = { ...this.managers, dimensionManagers: [ diff --git a/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte b/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte index 9bf2260a68f9..28174040a0cb 100644 --- a/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte +++ b/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte @@ -6,6 +6,7 @@ import DimensionFilter from "@rilldata/web-common/features/dashboards/filters/dimension-filters/DimensionFilter.svelte"; import MeasureFilter from "@rilldata/web-common/features/dashboards/filters/measure-filters/MeasureFilter.svelte"; import Button from "../../../components/button/Button.svelte"; + import AdvancedFilter from "@rilldata/web-common/features/dashboards/filters/AdvancedFilter.svelte"; let { id = "expression-filter", @@ -32,42 +33,48 @@ ); -
- +{#if !expressionFilterManager.isComplexFilter} +
+ - -
- - + +
+{/if}
- {#each expressionFilterManager.filterManagers.dimensions as dimensionManager (dimensionManager.name)} - - {/each} + {#if expressionFilterManager.isComplexFilter} + {#each Object.entries(expressionFilterManager.exprByMetricsView) as [mv, expr] (mv)} + + {/each} + {:else} + {#each expressionFilterManager.filterManagers.dimensions as dimensionManager (dimensionManager.name)} + + {/each} - {#each expressionFilterManager.filterManagers.measures as measureManager (measureManager.name)} - - {/each} + {#each expressionFilterManager.filterManagers.measures as measureManager (measureManager.name)} + + {/each} + {/if}
diff --git a/web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte b/web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte deleted file mode 100644 index e36642e323e1..000000000000 --- a/web-common/src/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -
- -
- - diff --git a/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte b/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte deleted file mode 100644 index e4596fb0c39e..000000000000 --- a/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterInput.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -
-
- -
- - diff --git a/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte b/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte deleted file mode 100644 index 3ca64b08ac79..000000000000 --- a/web-common/src/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
- {#if expressionFilterManager && dimOrMesManager} - {#if dimOrMesManager instanceof DimensionFilterManager} - - {:else if dimOrMesManager instanceof MeasureFilterManager} - - {/if} - {:else} - {advancedFilter.sql} - {/if} -
diff --git a/web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts b/web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts deleted file mode 100644 index 229240618db0..000000000000 --- a/web-common/src/features/dashboards/filters/advanced-filters/advanced-filter.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { - MetricsViewSpecDimension, - MetricsViewSpecMeasure, -} from "@rilldata/web-common/runtime-client"; - -export const ADVANCED_FILTER_TAG = "advanced-filter"; - -export type AdvancedFilterMentionOption = - | { type: "dimension"; dimension: MetricsViewSpecDimension } - | { type: "measure"; measure: MetricsViewSpecMeasure }; - -export type AdvancedFilter = { - sql: string; - name: string; -}; - -export function convertAdvancedFilterToHTML(advancedFilter: AdvancedFilter) { - return `<${ADVANCED_FILTER_TAG} name="${advancedFilter.name}">${advancedFilter.sql}`; -} - -const htmlTagStartRegex = new RegExp( - `<${ADVANCED_FILTER_TAG} name=".*?">`, - "g", -); -const htmlTagEndRegex = new RegExp(``, "g"); - -export function convertHTMLToSql(html: string) { - return html.replace(htmlTagStartRegex, "").replace(htmlTagEndRegex, ""); -} diff --git a/web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts b/web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts deleted file mode 100644 index 44deff90bf3d..000000000000 --- a/web-common/src/features/dashboards/filters/advanced-filters/plugins.svelte.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Mention, type MentionOptions } from "@tiptap/extension-mention"; -import { - ADVANCED_FILTER_TAG, - type AdvancedFilter, - type AdvancedFilterMentionOption, - convertAdvancedFilterToHTML, -} from "@rilldata/web-common/features/dashboards/filters/advanced-filters/advanced-filter.ts"; -import { getAllContexts, mount, unmount } from "svelte"; -import AdvancedFilterPill from "@rilldata/web-common/features/dashboards/filters/advanced-filters/AdvancedFilterPill.svelte"; -import type { - MetricsViewSpecDimension, - MetricsViewSpecMeasure, -} from "@rilldata/web-common/runtime-client"; -import AddAdvancedFilter from "@rilldata/web-common/features/dashboards/filters/advanced-filters/AddAdvancedFilter.svelte"; -import { Placeholder, UndoRedo } from "@tiptap/extensions"; -import type { EditorView } from "@tiptap/pm/view"; -import Document from "@tiptap/extension-document"; -import Paragraph from "@tiptap/extension-paragraph"; -import Text from "@tiptap/extension-text"; -import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -import { parseInlineAttr } from "@rilldata/web-common/features/chat/core/context/inline-context.ts"; - -export function getAdvancedFilterEditorPlugins({ - expressionFilterManager, - dimensions, - measures, -}: { - expressionFilterManager: ExpressionFilterManager; - dimensions: MetricsViewSpecDimension[]; - measures: MetricsViewSpecMeasure[]; -}) { - const plugins = [ - Document, - Paragraph, - Text, - Placeholder.configure({ - placeholder: "Enter advanced filter. Press @ for dimensions/measures", - }), - configureAdvancedFilterTipTapExtension( - expressionFilterManager, - dimensions, - measures, - ), - UndoRedo, - ]; - - return plugins; -} - -type FilterPillOptions = MentionOptions< - AdvancedFilterMentionOption, - AdvancedFilter -> & { - expressionFilterManager: ExpressionFilterManager | undefined; - allParentContexts: Map; -}; - -const FilterPillPlugin = Mention.extend({ - addOptions() { - return { - ...((this.parent?.() ?? {}) as MentionOptions), - expressionFilterManager: undefined, - // These have to be configured for the extension to work - allParentContexts: new Map(), - }; - }, - - // Mapping for attributes. We need to map values in InlineChatContext to html attribute and vice-versa. - addAttributes() { - return { - name: createAttributeEntry("name"), - sql: createAttributeEntry("sql"), - }; - }, - - parseHTML() { - return [{ tag: ADVANCED_FILTER_TAG }]; - }, - - renderHTML({ HTMLAttributes }) { - return [ADVANCED_FILTER_TAG, HTMLAttributes, ""]; - }, - - renderText({ node }) { - return convertAdvancedFilterToHTML(node.attrs as AdvancedFilter); - }, - - addNodeView() { - return ({ node, getPos, view, editor }) => { - // Create a wrapper div to render the component. - // We need this since svelte only takes a target wrapper. - const target = document.createElement("div"); - // We need this here to make sure the component is rendered inline. - target.className = "inline-block"; - - const { allParentContexts, expressionFilterManager } = this.options; - - // Create the inline chat context component. Pass the wrapper as the target. - const comp = mount(AdvancedFilterPill, { - target, - props: { - advancedFilter: node.attrs as AdvancedFilter, - expressionFilterManager, - onChange: (advancedFilter: AdvancedFilter) => { - const pos = getPos(); - if (!pos) return; - - // Dispatch a transaction to update the node attributes with the new context. - view.dispatch(getTransactionForFilter(advancedFilter, view, pos)); - editor.commands.focus(); - }, - focusEditor: () => editor.commands.focus(), - }, - context: allParentContexts, - }); - - return { - dom: target, - destroy() { - unmount(comp); - }, - }; - }; - }, -}); - -export function configureAdvancedFilterTipTapExtension( - expressionFilterManager: ExpressionFilterManager, - dimensions: MetricsViewSpecDimension[], - measures: MetricsViewSpecMeasure[], -) { - let comp: Record | null = null; - const pickerProps: Record = $state({}); - - const allParentContexts = getAllContexts(); - - return FilterPillPlugin.configure({ - expressionFilterManager, - allParentContexts, - suggestion: { - char: "@", - allowSpaces: true, - items: () => [ - ...dimensions.map( - (d) => - { type: "dimension", dimension: d }, - ), - ...measures.map( - (m) => { type: "measure", measure: m }, - ), - ], - render: () => ({ - onStart: (props) => { - if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment - - pickerProps.items = props.items; - pickerProps.refNode = props.decorationNode; - pickerProps.onSelect = (item: AdvancedFilter) => { - props.command(item); - }; - comp = mount(AddAdvancedFilter, { - target: document.body, - props: pickerProps, - context: allParentContexts, - }); - }, - - onUpdate(props) { - if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment - pickerProps.items = props.items; - pickerProps.refNode = props.decorationNode; - }, - - onExit: () => { - if (!comp) return; - unmount(comp); - comp = null; - }, - }), - }, - }); -} - -function getTransactionForFilter( - advancedFilter: AdvancedFilter, - view: EditorView, - pos: number, -) { - return view.state.tr - .setNodeAttribute(pos, "sql", advancedFilter.sql) - .setNodeAttribute(pos, "name", advancedFilter.name); -} - -function createAttributeEntry(key: string) { - return { - default: null, - parseHTML: (element: HTMLElement) => - element.getAttribute(key) ?? // Parsing from html attribute. - parseInlineAttr(element.innerHTML, key) ?? // Parsing from inline prompt. - null, - }; -} 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 index 56d6cb1bb396..4ba51ae55520 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts @@ -12,6 +12,8 @@ import { 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); @@ -37,6 +39,28 @@ export class DimensionFilterManager { 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[] = []; @@ -190,7 +214,6 @@ export class DimensionFilterManager { this.mode === DimensionFilterMode.InList ? [this.name] : [], ) : ""; - console.log("committing", this.param); } } 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 index 133de097c6c8..375a9a57366e 100644 --- a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts @@ -13,6 +13,8 @@ import { 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); @@ -33,6 +35,26 @@ export class MeasureFilterManager { 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; diff --git a/web-common/src/features/dashboards/filters/utils.ts b/web-common/src/features/dashboards/filters/utils.ts index 98b43b292e3f..6adb5a779026 100644 --- a/web-common/src/features/dashboards/filters/utils.ts +++ b/web-common/src/features/dashboards/filters/utils.ts @@ -7,7 +7,7 @@ export function getMissingRequiredFilters( ) { return Object.keys(yamlConfigProvider.requiredFilters).filter( (filterName) => { - return !expressionFilterManager.filterManagersMap[filterName]?.[0]?.expr; + return !expressionFilterManager.filterManagersMap[filterName]?.expr; }, ); } diff --git a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts index eb09dead26c9..b1f5e187f522 100644 --- a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -38,10 +38,18 @@ export class YAMLConfigProvider { } public togglePinnedFilter(filter: string) { - this.pinnedFilters[filter] = !this.pinnedFilters[filter]; + if (!this.pinnedFilters[filter]) { + this.pinnedFilters[filter] = true; + } else { + delete this.pinnedFilters[filter]; + } } public toggleRequiredFilter(filter: string) { - this.requiredFilters[filter] = !this.requiredFilters[filter]; + if (!this.requiredFilters[filter]) { + this.requiredFilters[filter] = true; + } else { + delete this.requiredFilters[filter]; + } } } From 60e9cc02cac77be1de52574e2e502c659bf8cc2a Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 12 Aug 2026 14:00:43 +0530 Subject: [PATCH 20/21] fix unit tests --- .../bookmarks/BookmarksFormDialog.svelte | 6 +-- .../src/features/public-urls/form-utils.ts | 7 +-- .../big-number/MeasuresContainer.svelte | 1 - .../filters/test/render-filter-component.ts | 31 ------------- .../test/DashboardStateManagerTest.svelte | 22 ++++++---- .../most-recent-explore-state.spec.ts | 18 +++++++- .../stores/AdvancedMeasureCorrector.spec.ts | 21 --------- .../stores/AdvancedMeasureCorrector.ts | 17 +------ .../stores/correct-explore-state.spec.ts | 21 --------- .../dashboards/stores/dashboard-stores.ts | 1 - .../dashboards/stores/test-data/helpers.ts | 44 ++++++++----------- .../stores/test-data/store-mutations.ts | 1 + .../time-controls/time-control-store.spec.ts | 11 ++++- .../time-series/multiple-dimension-queries.ts | 6 +-- .../ScheduledReportDialog.svelte | 1 - web-local/tests/explores/explores.spec.ts | 2 + 16 files changed, 68 insertions(+), 142 deletions(-) delete mode 100644 web-common/src/features/dashboards/filters/test/render-filter-component.ts diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index 66f4b56f1b54..b71ece2cc1f7 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -160,16 +160,12 @@ const bookmarkCreator = createAdminServiceCreateBookmark(); const bookmarkUpdater = createAdminServiceUpdateBookmark(); + // svelte-ignore state_referenced_locally const initialValues = { - // eslint-disable-next-line svelte/valid-compile displayName: bookmark?.resource.displayName || m.bookmark_default_label(), - // eslint-disable-next-line svelte/valid-compile description: bookmark?.resource.description ?? "", - // eslint-disable-next-line svelte/valid-compile shared: bookmark?.resource.shared ? "true" : "false", - // eslint-disable-next-line svelte/valid-compile filtersOnly: bookmark?.filtersOnly ?? false, - // eslint-disable-next-line svelte/valid-compile absoluteTimeRange: bookmark?.absoluteTimeRange ?? false, }; diff --git a/web-admin/src/features/public-urls/form-utils.ts b/web-admin/src/features/public-urls/form-utils.ts index 4833443190f8..6b6f8df1500e 100644 --- a/web-admin/src/features/public-urls/form-utils.ts +++ b/web-admin/src/features/public-urls/form-utils.ts @@ -71,19 +71,20 @@ export function createFieldsAndStateForKind( validSpecState, timeControlState, ]) => { - const metricsView = validSpecState.data?.metricsView ?? {}; + const metricsViewSpec = validSpecState.data?.metricsView ?? {}; + const exploreSpec = validSpecState.data?.explore ?? {}; const exploreFields = getExploreFields( dashboardState, expr, - metricsView.dimensions ?? [], + metricsViewSpec.dimensions ?? [], $visibleDimensions, $visibleMeasures, ); const sanitizedState = getSanitizedExploreStateParam( dashboardState, exploreFields, - validSpecState.data?.explore, + exploreSpec, ); return { diff --git a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte index 27d4951d5042..697a6fdc26ce 100644 --- a/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte +++ b/web-common/src/features/dashboards/big-number/MeasuresContainer.svelte @@ -31,7 +31,6 @@ const ctx = getStateManagers(); const { - dashboardStore, selectors: { measures: { allMeasures, visibleMeasures }, }, 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/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 ae131ecabd5f..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/test/url-state-test-utils"; +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/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 f85bfc790ff1..09fff3e8766f 100644 --- a/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts +++ b/web-common/src/features/dashboards/stores/AdvancedMeasureCorrector.ts @@ -48,27 +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() { - // TODO: correct whereFilter - // 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/correct-explore-state.spec.ts b/web-common/src/features/dashboards/stores/correct-explore-state.spec.ts index 073dbaeaac94..f9a6f2c91491 100644 --- a/web-common/src/features/dashboards/stores/correct-explore-state.spec.ts +++ b/web-common/src/features/dashboards/stores/correct-explore-state.spec.ts @@ -1,7 +1,3 @@ -import { - MeasureFilterOperation, - MeasureFilterType, -} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options"; import { getFullInitExploreState } from "@rilldata/web-common/features/dashboards/stores/dashboard-store-defaults"; import { getInitExploreStateForTest } from "@rilldata/web-common/features/dashboards/stores/test-data/helpers"; import type { DashboardTimeControls } from "@rilldata/web-common/lib/time/types"; @@ -19,7 +15,6 @@ import { AD_BIDS_IMPRESSIONS_MEASURE_NO_GRAIN, AD_BIDS_IMPRESSIONS_MEASURE_WINDOW, AD_BIDS_METRICS_INIT, - AD_BIDS_PUBLISHER_DIMENSION, AD_BIDS_TIME_RANGE_SUMMARY, AD_BIDS_TIMESTAMP_DIMENSION, } from "./test-data/data"; @@ -80,26 +75,11 @@ describe("correctExploreState", () => { 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: "", - }, - ], - }, - ]; correctExploreState(MetricsView, dashboard); 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 const updatedMetricsView = { @@ -143,6 +123,5 @@ describe("correctExploreState", () => { 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/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 3009cc570000..55b856dd89ab 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -5,7 +5,6 @@ import { correctExploreState } from "@rilldata/web-common/features/dashboards/st import { type ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; import { createAndExpression, - filterExpressions, forEachIdentifier, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; diff --git a/web-common/src/features/dashboards/stores/test-data/helpers.ts b/web-common/src/features/dashboards/stores/test-data/helpers.ts index f861a30ee9a1..4b9a9c5b9712 100644 --- a/web-common/src/features/dashboards/stores/test-data/helpers.ts +++ b/web-common/src/features/dashboards/stores/test-data/helpers.ts @@ -1,6 +1,5 @@ -import { QueryClient } from "@tanstack/svelte-query"; import { createStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; -import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { renderInRuntimeContext } from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts"; import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; @@ -103,36 +102,31 @@ export function assertMetricsViewRaw( expect(metricsView.leaderboardSortByMeasureName).toEqual(selectedMeasure); } -export function initStateManagers(metricsViewName?: string) { - metricsViewName ??= AD_BIDS_NAME; +/** + * createStateManagers builds providers that call `createQuery`, so it can only run during + * component initialisation. renderInRuntimeContext mounts a harness that supplies the + * QueryClient context and an effect owner. Callers own the returned `destroy`. + */ +export function initStateManagers(metricsViewName = AD_BIDS_NAME) { const exploreName = metricsViewName + "_explore"; metricsExplorerStore.remove(AD_BIDS_EXPLORE_NAME); initAdBidsInStore(); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - networkMode: "always", - }, - }, - }); - const runtimeClient = new RuntimeClient({ - host: "http://localhost", - instanceId: "test", - }); - const stateManagers = createStateManagers({ + const { + value: stateManagers, queryClient, - metricsViewName, - exploreName, - runtimeClient, - }); + destroy, + } = renderInRuntimeContext(({ queryClient, runtimeClient }) => + createStateManagers({ + queryClient, + metricsViewName, + exploreName, + runtimeClient, + }), + ); - return { stateManagers, queryClient }; + return { stateManagers, queryClient, destroy }; } export function getPartialDashboard( diff --git a/web-common/src/features/dashboards/stores/test-data/store-mutations.ts b/web-common/src/features/dashboards/stores/test-data/store-mutations.ts index 554100842bea..80f127821ce4 100644 --- a/web-common/src/features/dashboards/stores/test-data/store-mutations.ts +++ b/web-common/src/features/dashboards/stores/test-data/store-mutations.ts @@ -579,6 +579,7 @@ export async function applyMutationsToDashboard( createAndExpression([]); dashboard.dimensionThresholdFilters = []; dashboard.dimensionsWithInlistFilter = filterManager.inList; + dashboard.dimensionFilterExcludeMode = new Map(); }); } // DashboardStateSync.gotoNewState that listens to changes to the dashboard store is an async function. diff --git a/web-common/src/features/dashboards/time-controls/time-control-store.spec.ts b/web-common/src/features/dashboards/time-controls/time-control-store.spec.ts index 1b9b60b2934d..040d1de481e3 100644 --- a/web-common/src/features/dashboards/time-controls/time-control-store.spec.ts +++ b/web-common/src/features/dashboards/time-controls/time-control-store.spec.ts @@ -408,14 +408,21 @@ describe("time-control-store", () => { metricsView, AD_BIDS_EXPLORE_INIT, ); - const { stateManagers, queryClient } = initStateManagers(); + const { stateManagers, queryClient, destroy } = initStateManagers(); const timeControlsStore = createTimeControlStore(stateManagers); const { unmount } = render(TimeControlsStoreTest, { timeControlsStore, }); - return { unmount, queryClient, timeControlsStore }; + return { + unmount: () => { + unmount(); + destroy(); + }, + queryClient, + timeControlsStore, + }; } }); 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 8b2877df880b..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 @@ -38,11 +38,7 @@ 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; diff --git a/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte b/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte index a326eb366ae0..d2d6a99c8b36 100644 --- a/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte +++ b/web-common/src/features/scheduled-reports/ScheduledReportDialog.svelte @@ -69,7 +69,6 @@ import { ResourceKind } from "../entity-management/resource-selectors"; import BaseScheduledReportForm from "./BaseScheduledReportForm.svelte"; import { convertFormValuesToCronExpression } from "./time-utils"; - import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; export let open: boolean; export let props: diff --git a/web-local/tests/explores/explores.spec.ts b/web-local/tests/explores/explores.spec.ts index 494db2b45f5a..d2ea0d7070c8 100644 --- a/web-local/tests/explores/explores.spec.ts +++ b/web-local/tests/explores/explores.spec.ts @@ -14,6 +14,7 @@ import { import { assertLeaderboards } from "../utils/metricsViewHelpers"; import { ResourceWatcher } from "../utils/ResourceWatcher"; import { createSourceV2 } from "../utils/sourceHelpers"; +import { asyncWait } from "@rilldata/web-common/lib/waitUtils.ts"; test.describe("explores", () => { test.use({ project: "Blank" }); @@ -30,6 +31,7 @@ test.describe("explores", () => { await assertAdBidsDashboard(page); // click on publisher=Facebook leaderboard value + await asyncWait(1000); await page.getByRole("row", { name: "Facebook 19.3k" }).click(); await wrapRetryAssertion(() => assertLeaderboards(page, [ From a8d142cf751eff0b80029af0e617b19e1ee1fdf8 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 12 Aug 2026 16:39:54 +0530 Subject: [PATCH 21/21] fix rill dev e2e tests --- .../src/features/bookmarks/Bookmarks.svelte | 2 - .../features/bookmarks/CanvasBookmarks.svelte | 10 -- .../bookmarks/ExploreBookmarks.svelte | 2 - .../projects/header/ProjectHeader.svelte | 1 - .../canvas/components/BaseCanvasComponent.ts | 4 +- .../canvas/components/kpi-grid/KPIGrid.svelte | 14 +-- .../leaderboard/LeaderboardDisplay.svelte | 1 - .../filters/DimensionFiltersInput.svelte | 13 ++- .../filters/ExpressionFilterManager.svelte.ts | 4 +- .../DimensionFilterReadOnlyChip.svelte | 94 ------------------- .../measure-filters/MeasureFilter.svelte | 1 + .../MeasureFilterReadOnlyChip.svelte | 22 ----- .../providers/MetricsViewsProvider.svelte.ts | 10 +- web-local/tests/canvas/filters.spec.ts | 1 - 14 files changed, 33 insertions(+), 146 deletions(-) delete mode 100644 web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterReadOnlyChip.svelte delete mode 100644 web-common/src/features/dashboards/filters/measure-filters/MeasureFilterReadOnlyChip.svelte diff --git a/web-admin/src/features/bookmarks/Bookmarks.svelte b/web-admin/src/features/bookmarks/Bookmarks.svelte index 79e18b03f9bc..1b83e7dff0cc 100644 --- a/web-admin/src/features/bookmarks/Bookmarks.svelte +++ b/web-admin/src/features/bookmarks/Bookmarks.svelte @@ -42,7 +42,6 @@ export let organization: string; export let project: string; export let resource: { name: string; kind: ResourceKind }; - export let metricsViewNames: string[]; export let bookmarkData: { bookmarks: V1Bookmark[]; categorizedBookmarks: Bookmarks; @@ -260,7 +259,6 @@ {resource} {defaultUrlParams} {showFiltersOnly} - {metricsViewNames} onClose={() => { showDialog = false; bookmark = null; diff --git a/web-admin/src/features/bookmarks/CanvasBookmarks.svelte b/web-admin/src/features/bookmarks/CanvasBookmarks.svelte index f4bb8f2da534..48a2bfb3c645 100644 --- a/web-admin/src/features/bookmarks/CanvasBookmarks.svelte +++ b/web-admin/src/features/bookmarks/CanvasBookmarks.svelte @@ -1,17 +1,13 @@
@@ -35,7 +46,7 @@ /> { + onCheckedChange={() => { if (localFiltersEnabled) { localFiltersEnabledOverride = false; updateLocalFilterString(""); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 54c12ec6510e..d159ef8f661d 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -65,9 +65,9 @@ export class ExpressionFilterManager { ) as typeof this.events.on; // Raw filter param per metrics view. - private paramByMetricsView = $state>({}); + public paramByMetricsView = $state>({}); // Filter param based on managers. - private paramByManager: Record; + public paramByManager: Record; public constructor( public readonly metricsViewsProvider: MetricsViewsProvider, 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/measure-filters/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte index 917449018718..c1b803578817 100644 --- a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte +++ b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilter.svelte @@ -132,6 +132,7 @@ {#if missingRequired} {m.dashboard_filter_required_set_value()} {:else} + {m.dashboard_click_to_edit_values()} 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/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts index e7cc264dcd31..35af7ade301d 100644 --- a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -11,6 +11,7 @@ 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; @@ -173,9 +174,12 @@ export class MetricsViewsProvider { const dimensions: MetricsViewSpecDimension[] = []; for (const metricsViewName of this.metricsViewNames) { - const spec = this.resources.find( - (resource) => resource.meta?.name?.name === metricsViewName, - )?.metricsView?.state?.validSpec; + 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; diff --git a/web-local/tests/canvas/filters.spec.ts b/web-local/tests/canvas/filters.spec.ts index 9d29068f6034..443ca2edbb73 100644 --- a/web-local/tests/canvas/filters.spec.ts +++ b/web-local/tests/canvas/filters.spec.ts @@ -69,7 +69,6 @@ test.describe("canvas time filters", () => { await page.getByRole("menuitem", { name: "Domain" }).click(); await page.getByLabel("domain results").getByText("facebook.com").click(); - await page .getByLabel("domain results") .getByText("google.com", { exact: true })