diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index 69a689de7a5c..6e07015827d5 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -1,5 +1,5 @@ @@ -286,24 +288,12 @@ {m.bookmark_filters_inherited()} - {#if filterState && "uiFilters" in filterState} - - {:else if filterState} - {/if} @@ -317,7 +307,7 @@ { value: "true", label: m.bookmark_managed_bookmarks() }, ]} slot="manage-project" - tooltip={CategoryTooltip} + tooltip={m.bookmark_category_tooltip()} /> {#if showFiltersOnly} @@ -366,9 +356,9 @@ - {#if filterState} + {#if timeFilterState}
- {filterState.selectedTimeRange} + {timeFilterState.selectedTimeRange}
{/if} 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 580a58d99cb4..104954be5579 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} @@ -204,17 +223,27 @@ {/if} - {#if isCanvas} - - {:else} - + {#if hasSomeFilter} +
+ +
+

+ {m.public_url_filters_locked_hidden()} +

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

+ {m.public_url_measures_dimensions_limited()} +

+ {/if} {/if} + {:else} - + {/if} {#if currentTabIndex !== 2} - + {:else} - {/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/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index 5fedd39791a5..65812e1c12a0 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -16,6 +16,7 @@ import { type V1MetricsViewSpec, type V1Resource, type V1ThemeSpec, + getQueryServiceConvertExpressionToMetricsSQLQueryKey, } from "@rilldata/web-common/runtime-client"; import { derived, @@ -37,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"; @@ -50,6 +49,15 @@ 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"; export const lastVisitedState = new Map(); @@ -90,11 +98,13 @@ export class CanvasEntity { // Time state controls timeManager: TimeManager; - // Dimension and measure filter state - filterManager: FilterManager; - // Metrics view selectors metricsView: MetricsViewSelectors; + metricsViewsProvider: MetricsViewsProvider; + yamlConfigProvider: YAMLConfigProvider; + + // Expression filter manager + expressionFilterManager: ExpressionFilterManager; fileArtifact: FileArtifact | undefined; @@ -114,7 +124,6 @@ export class CanvasEntity { // This may sometimes be false due to discrepancy between two different ways // of storing the same state in the URL namely dimension IN (['value']) vs dimension IN ('value') defaultUrlParamsStore = writable(new URLSearchParams()); - viewingDefaultsStore: Readable; filtersEnabledStore = writable(true); _embeddedTheme = writable(undefined); _metricsViews = writable>({}); @@ -203,65 +212,21 @@ export class CanvasEntity { // override is applied. this.themeName.set(undefined); - this.processSpec(this.spec); - this.metricsView = new MetricsViewSelectors( this.client, this._metricsViews, ); + this.metricsViewsProvider = new MetricsViewsProvider(this.client, []); + this.yamlConfigProvider = new CanvasConfigProvider(this.client, name); - this.viewingDefaultsStore = derived( - [ - this.searchParams, - this.defaultUrlParamsStore, - this.filterManager.pinnedFilterKeysStore, - this.filterManager.defaultPinnedFilterKeysStore, - this.filterManager.requiredFilterKeysStore, - this.filterManager.defaultRequiredFilterKeysStore, - ], - ([ - $searchParams, - $defaultUrlParams, - pinnedFilters, - defaultPinnedFilterKeys, - requiredFilters, - defaultRequiredFilterKeys, - ]) => { - if ( - defaultPinnedFilterKeys.symmetricDifference(pinnedFilters).size > 0 - ) { - return false; - } - if ( - defaultRequiredFilterKeys.symmetricDifference(requiredFilters).size > - 0 - ) { - return false; - } - if ($defaultUrlParams.size === 0) { - return false; - } - - for (const [key, value] of $defaultUrlParams.entries()) { - if ($searchParams.get(key) !== value) { - // Ignore time range if not set - if ( - $searchParams.get(key) === null && - key === ExploreStateURLParams.TimeRange - ) { - continue; - } - return false; - } - } - for (const [key, value] of $searchParams.entries()) { - if ($defaultUrlParams.get(key) !== value) { - return false; - } - } - return true; - }, + this.expressionFilterManager = new ExpressionFilterManager( + this.metricsViewsProvider, + this.yamlConfigProvider, ); + // Create the URL sync component + new ExpressionFilterURLSync(this.expressionFilterManager); + + this.processSpec(this.spec); } checkAndSetMaxWidth = ({ maxWidth }: V1CanvasSpec) => { @@ -334,6 +299,7 @@ export class CanvasEntity { if (!validSpec) return; if (metricsViews) this._metricsViews.set(metricsViews); + this.metricsViewsProvider.setMetricsViewNames(Object.keys(metricsViews)); this.checkAndSetFilterEnabled(validSpec); this.checkAndSetFileArtifact(filePath); @@ -346,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 }); }; @@ -390,32 +325,35 @@ export class CanvasEntity { setTimeout(resolve, 100); }); - const pinnedFilters = get(this.filterManager.pinnedFilterKeysStore); - const requiredFilters = get(this.filterManager.requiredFilterKeysStore); + const pinnedFilters = Object.keys(this.yamlConfigProvider.pinnedFilters); + const requiredFilters = Object.keys( + this.yamlConfigProvider.requiredFilters, + ); // 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); - const metricsViewFilters = get(this.filterManager.metricsViewFilters); - const filterNames = Array.from(metricsViewFilters.keys()); - const promises = Array.from(metricsViewFilters.values()).map((filters) => { - const parsed = get(filters.parsed); + 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. return queryClient.fetchQuery({ - queryKey: [ - "resolve-metrics-view-filter-expression", + queryKey: getQueryServiceConvertExpressionToMetricsSQLQueryKey( this.instanceId, - parsed.where, - ], + { expression: protoExpr }, + ), queryFn: () => queryServiceConvertExpressionToMetricsSQL(this.client, { - expression: parsed.where as any, + expression: protoExpr, }), }); }); @@ -543,7 +481,6 @@ export class CanvasEntity { if (redirected) return; } - this.filterManager.onUrlChange(searchParams); this.searchParams.set(searchParams); if (!isolated) { this.saveSnapshot(searchParams.toString()); @@ -621,6 +558,7 @@ export class CanvasEntity { const deployed = projectId; if (deployed) { + // TODO: bookmark specific code should only be in web-admin let homeBookmarkUrlSearch: string | undefined = undefined; try { // Only gets imported in admin context @@ -1088,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/aggregation-request-utils.ts b/web-common/src/features/dashboards/aggregation-request-utils.ts index e7948991536b..9909d21052a6 100644 --- a/web-common/src/features/dashboards/aggregation-request-utils.ts +++ b/web-common/src/features/dashboards/aggregation-request-utils.ts @@ -1,10 +1,7 @@ import { getAggregationDimensionFromFieldName } from "@rilldata/web-common/features/dashboards/aggregation-request/dimension-utils.ts"; import { getComparisonRequestMeasures } from "@rilldata/web-common/features/dashboards/dashboard-utils.ts"; import { MeasureModifierSuffixRegex } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts"; -import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils.ts"; import { ComparisonModifierSuffixRegex } from "@rilldata/web-common/features/dashboards/pivot/types.ts"; -import { sanitiseExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.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 { mapSelectedComparisonTimeRangeToV1TimeRange, @@ -56,22 +53,6 @@ export const aggregationRequestWithTimeRange = ( }; }; -export const aggregationRequestWithFilters = (filtersState: FiltersState) => { - return (aggregationRequest: V1MetricsViewAggregationRequest) => { - const whereFilter = sanitiseExpression( - mergeDimensionAndMeasureFilters( - filtersState.whereFilter, - filtersState.dimensionThresholdFilters, - ), - undefined, - ); - return { - ...aggregationRequest, - where: whereFilter, - }; - }; -}; - export const aggregationRequestWithRowsAndColumns = ({ exploreSpec, rows, diff --git a/web-common/src/features/dashboards/dashboard-fetch-mocks.ts b/web-common/src/features/dashboards/dashboard-fetch-mocks.ts index 7c699609561e..07735bcd0610 100644 --- a/web-common/src/features/dashboards/dashboard-fetch-mocks.ts +++ b/web-common/src/features/dashboards/dashboard-fetch-mocks.ts @@ -5,6 +5,7 @@ import type { V1GetResourceResponse, V1MetricsViewAggregationResponse, V1MetricsViewSpec, + V1Resource, V1TimeRangeSummary, } from "@rilldata/web-common/runtime-client"; import { afterAll, beforeAll, vi } from "vitest"; @@ -12,6 +13,9 @@ import { asyncWait } from "../../lib/waitUtils"; export class DashboardFetchMocks { private responses = new Map(); + // Every resource mocked so far, keyed by name. GetResource serves one of these, + // ListResources serves all of them. + private resources = new Map(); private aggregationRequestMocks: { regex: RegExp; response: V1MetricsViewAggregationResponse; @@ -32,20 +36,22 @@ export class DashboardFetchMocks { } public mockMetricsView(name: string, resp: V1MetricsViewSpec) { - this.responses.set(`resource__${name}`, { - resource: { - meta: { - name: { - kind: ResourceKind.MetricsView, - name, - }, + const resource: V1Resource = { + meta: { + name: { + kind: ResourceKind.MetricsView, + name, }, - metricsView: { - state: { - validSpec: resp, - }, + }, + metricsView: { + state: { + validSpec: resp, }, }, + }; + this.resources.set(name, resource); + this.responses.set(`resource__${name}`, { + resource, } as V1GetResourceResponse); } @@ -201,6 +207,11 @@ export class DashboardFetchMocks { } else if (service === "RuntimeService" && method === "GetResource") { const name = parsed.name?.name; responseData = this.responses.get(`resource__${name}`); + } else if (service === "RuntimeService" && method === "ListResources") { + const resources = [...this.resources.values()].filter( + (resource) => !parsed.kind || resource.meta?.name?.kind === parsed.kind, + ); + responseData = { resources }; } else if ( service === "QueryService" && method === "MetricsViewTimeRange" diff --git a/web-common/src/features/dashboards/dimension-search/GlobalDimensionSearchResults.svelte b/web-common/src/features/dashboards/dimension-search/GlobalDimensionSearchResults.svelte index fe1554a3126d..880f4ccfa3c2 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 44ace3a8b1d1..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; @@ -47,7 +43,6 @@ const { selectors: { - dimensionFilters: { unselectedDimensionValues }, dimensionTable: { virtualizedTableColumns, prepareDimTableRows }, sorting: { sortedAscending, sortType }, leaderboard: { @@ -55,15 +50,9 @@ leaderboardSortByMeasureName, }, }, - actions: { - dimensionsFilter: { - toggleDimensionValueSelection, - selectItemsInFilter, - deselectItemsInFilter, - }, - }, dashboardStore, validSpecStore, + expressionFilterManager, } = getStateManagers(); $: metricsViewSpec = $validSpecStore.data?.metricsView ?? {}; @@ -75,7 +64,7 @@ $: selectedValues = selectedDimensionValues( client, [metricsViewName], - $dashboardStore.whereFilter, + whereFilter, dimensionName, timeRange.start, timeRange.end, @@ -92,7 +81,7 @@ $leaderboardShowContextForAllMeasures ? null : $leaderboardSortByMeasureName, - dimensionThresholdFilters, + whereFilter, visibleMeasureNames, ).map((name) => ({ name }) as V1MetricsViewAggregationMeasure), @@ -125,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, }, { @@ -162,11 +145,6 @@ !!comparisonTimeRange, ); - $: where = sanitiseExpression( - mergeDimensionAndMeasureFilters(filterSet, dimensionThresholdFilters), - undefined, - ); - $: sortedQuery = createQueryServiceMetricsViewAggregation( client, { @@ -176,7 +154,7 @@ timeRange, comparisonTimeRange, sort, - where, + where: whereFilter, limit: queryLimit.toString(), offset: "0", }, @@ -200,14 +178,20 @@ 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: m.dashboard_removed_items_filter({ @@ -216,11 +200,10 @@ }); return; } else { - const newValuesSelected = $unselectedDimensionValues( + const newValuesSelected = expressionFilterManager.dimensionFilterAction( dimensionName, - labels, + (dimensionManager) => dimensionManager.appendSelectedValues(labels), ); - selectItemsInFilter(dimensionName, labels); eventBus.emit("notification", { message: m.dashboard_added_items_filter({ count: newValuesSelected.length.toString(), diff --git a/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte b/web-common/src/features/dashboards/dimension-table/DimensionHeader.svelte index 5efabf64269d..b648dc3a9881 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.filterManagers.dimensions.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 9bae458e94f2..5fc08a2e2664 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 hasClearableFilters} + + {/if} + + diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index a047aebf6125..10d2dabeecab 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -1,15 +1,8 @@ + +
+ +
diff --git a/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte b/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte new file mode 100644 index 000000000000..d906fb194b4a --- /dev/null +++ b/web-common/src/features/dashboards/filters/ReadonlyExpressionFilters.svelte @@ -0,0 +1,104 @@ + + +
+ {#if displayTimeRange} + + {/if} + + {#each nonEmptyDimensionManagers as dimensionManager (dimensionManager.name)} +
+ +
+ {/each} + + {#each nonEmptyMeasureManager as measureManager (measureManager.name)} +
+ +
+ {/each} +
+ + diff --git a/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte b/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte new file mode 100644 index 000000000000..4e116a7090cb --- /dev/null +++ b/web-common/src/features/dashboards/filters/VerticalExpressionFilters.svelte @@ -0,0 +1,80 @@ + + +
+ + + +
+ + + +
+
+ {#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/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 new file mode 100644 index 000000000000..8a876a3ceb67 --- /dev/null +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilter.svelte @@ -0,0 +1,577 @@ + + + { + if (open && e.key === "Enter") onApply(); + }} +/> + + + + {#snippet child({ props })} + + dimensionManager.clear()} + removable={removable && !pinned && !required} + removeTooltipText="remove {dimensionManager.selectedValues + .length} value{dimensionManager.selectedValues.length !== 1 + ? 's' + : ''}" + > + + +
+ + + {dimensionManager.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 yamlConfigProvider.editable} +
+ {dimensionManager.label} + +
+ (curRequired = !curRequired)} + /> + (curPinned = !curPinned)} + /> +
+
+ {/if} +
+ + +
+ {#if showExtraInfo} +
+ {#if proxyDimensionManager.mode !== 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 proxyDimensionManager.mode === 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 proxyDimensionManager.mode === DimensionFilterMode.Select && !curSearchText && checkedItems.length > 0 && uncheckedItems.length > 0} + + {/if} + + + {#each uncheckedItems as name (name)} + {@const selected = effectiveSelectedValues.includes(name)} + {@const label = name ?? "null"} + {@const ItemComponent = + proxyDimensionManager.mode === DimensionFilterMode.Select + ? DropdownMenu.CheckboxItem + : DropdownMenu.Item} + + handleItemClick(name)} + > + + {#if label.length > 240} + {label.slice(0, 240)}... + {:else} + {label} + {/if} + + + {/each} + + + {#if uncheckedItems.length === 0 && (proxyDimensionManager.mode !== DimensionFilterMode.Select || checkedItems.length === 0)} +
+ no results +
+ {/if} +
+ {/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 new file mode 100644 index 000000000000..113cd2faecda --- /dev/null +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/DimensionFilterManager.svelte.ts @@ -0,0 +1,198 @@ +import { page } from "$app/state"; +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; +import { + type V1Expression, + V1Operation, +} from "@rilldata/web-common/runtime-client"; +import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts"; +import { + createInExpression, + createLikeExpression, + getValuesInExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; + +export class DimensionFilterManager { + public expr: V1Expression | undefined = $state(undefined); + // String representation of the filter expression. Used to check duplicate expressions across metrics views. + public param: string = $state(""); + + public mode = $state(DimensionFilterMode.Select); + public selectedValues = $state([]); + public inputText = $state(""); + public exclude = $state(false); + + private oldMode: DimensionFilterMode; + + public constructor( + public readonly name: string, + public readonly label: string, + initExpr: V1Expression = createInExpression(name, []), + isInList: boolean = false, + // Filter dropdown doesnt immediately apply changes. + // This marks this manager as ephemeral, it will not notify about mode changes. + private readonly ephemeral: boolean = false, + ) { + this.reconcile(initExpr, isInList ? [name] : []); + } + + public reconcile(expr: V1Expression, inList: string[]) { + let initMode: DimensionFilterMode = DimensionFilterMode.Select; + let initSelectedValues: string[] = []; + let initInputText: string = ""; + let initExclude: boolean = false; + + const op = expr.cond?.op; + if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { + initMode = inList.includes(this.name) + ? DimensionFilterMode.InList + : DimensionFilterMode.Select; + initSelectedValues = getValuesInExpression(expr); + initExclude = op === V1Operation.OPERATION_NIN; + } else if ( + op === V1Operation.OPERATION_LIKE || + op === V1Operation.OPERATION_NLIKE + ) { + initMode = DimensionFilterMode.Contains; + initInputText = sanitizeSearchText( + expr.cond?.exprs?.[1]?.val?.toString?.() ?? "", + ); + initExclude = op === V1Operation.OPERATION_NLIKE; + } + + this.mode = initMode; + this.oldMode = initMode; + this.selectedValues = initSelectedValues; + this.inputText = initInputText; + this.exclude = initExclude; + this.commit(false); + } + + public clone() { + return new DimensionFilterManager( + this.name, + this.label, + this.expr, + this.mode === DimensionFilterMode.InList, + true, + ); + } + + public apply(dimensionManager: DimensionFilterManager) { + this.mode = dimensionManager.mode; + this.selectedValues = [...dimensionManager.selectedValues]; + this.inputText = dimensionManager.inputText; + this.exclude = dimensionManager.exclude; + this.commit(); + } + + public setSelectedValues(dimensionValues: string[], exclude: boolean) { + this.mode = DimensionFilterMode.Select; + this.selectedValues = dimensionValues; + this.inputText = ""; + this.exclude = exclude; + this.commit(); + } + + public toggleValue(dimensionValue: string, isExclusiveFilter: boolean) { + const inIdx = this.selectedValues.findIndex((v) => v === dimensionValue); + + if (inIdx === -1) { + if (isExclusiveFilter) { + this.selectedValues = [dimensionValue]; + } else { + this.selectedValues = [...this.selectedValues, dimensionValue]; + } + } else { + this.selectedValues = this.selectedValues.toSpliced(inIdx, 1); + } + this.commit(); + } + + public appendSelectedValues(dimensionValues: string[]) { + const newValues = dimensionValues.filter( + (v) => !this.selectedValues.includes(v), + ); + this.selectedValues = [...this.selectedValues, ...newValues]; + this.commit(); + return newValues; + } + + public removeSelectedValues(dimensionValues: string[]) { + this.selectedValues = this.selectedValues.filter( + (v) => !dimensionValues.includes(v), + ); + this.commit(); + } + + public setInList(values: string[], exclude: boolean) { + this.mode = DimensionFilterMode.InList; + this.selectedValues = values; + this.inputText = ""; + this.exclude = exclude; + this.commit(); + } + + public setContainsText(searchText: string, exclude: boolean) { + this.mode = DimensionFilterMode.Contains; + this.selectedValues = []; + this.inputText = searchText; + this.exclude = exclude; + this.commit(); + } + + public toggleExclude() { + this.exclude = !this.exclude; + this.commit(); + } + + public clear() { + this.selectedValues = []; + this.inputText = ""; + this.commit(); + } + + public commit(notify: boolean = true) { + switch (this.mode) { + case DimensionFilterMode.Select: + if ( + this.oldMode !== DimensionFilterMode.Select && + !this.ephemeral && + notify + ) { + eventBus.emit("notification", { + message: m.filter_converted_to_select(), + link: { + text: m.common_undo(), + href: page.url.href, + }, + }); + } + // eslint-disable-next-line no-fallthrough + case DimensionFilterMode.InList: + this.expr = this.selectedValues.length + ? createInExpression(this.name, this.selectedValues, this.exclude) + : undefined; + break; + + case DimensionFilterMode.Contains: + this.expr = this.inputText + ? createLikeExpression(this.name, `%${this.inputText}%`, this.exclude) + : undefined; + break; + } + this.oldMode = this.mode; + + this.param = this.expr + ? convertExpressionToFilterParam( + this.expr, + this.mode === DimensionFilterMode.InList ? [this.name] : [], + ) + : ""; + } +} + +export function sanitizeSearchText(searchText: string) { + return searchText.replace(/^%/, "").replace(/%$/, ""); +} diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte new file mode 100644 index 000000000000..2f6e84d776cd --- /dev/null +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/ReadonlyDimensionFilter.svelte @@ -0,0 +1,126 @@ + + + + + diff --git a/web-common/src/features/dashboards/filters/dimension-filters-v2/queries.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters-v2/queries.svelte.ts new file mode 100644 index 000000000000..11184b0611e1 --- /dev/null +++ b/web-common/src/features/dashboards/filters/dimension-filters-v2/queries.svelte.ts @@ -0,0 +1,217 @@ +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { + getQueryServiceMetricsViewAggregationQueryOptions, + V1BuiltinMeasure, + type V1Expression, +} from "@rilldata/web-common/runtime-client"; +import { + createAndExpression, + createInExpression, + createLikeExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; +import { createReactiveQueries } from "@rilldata/web-common/lib/svelte-query/reactive-queries.svelte.ts"; + +type DimensionSearchArgs = { + manager: ExpressionFilterManager; + dimensionName: string; + mode: DimensionFilterMode; + searchText: string; + values: string[]; + timeStart?: string; + timeEnd?: string; + timeDimension?: string; + enabled?: boolean; +}; + +/** + * Returns the search results from the search input in a dimension filter. + * + * A dimension can be defined by more than one metrics view, so this queries each of them and merges + * the values. `getArgs` is read reactively, as are the specs, so the queries follow both the input + * in the dropdown and the metrics views as they load. + * + * Must be called during component init, once per dimension filter. + */ +export function createDimensionSearchQuery( + client: RuntimeClient, + getArgs: () => DimensionSearchArgs, +) { + return createReactiveQueries( + () => { + const { + manager, + dimensionName, + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + } = getArgs(); + + return getMetricsViewsForDimension(manager, dimensionName).map( + (metricsView) => + getQueryServiceMetricsViewAggregationQueryOptions( + client, + { + metricsView, + dimensions: [{ name: dimensionName }], + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + limit: "250", + offset: "0", + sort: [{ name: dimensionName }], + where: getFilterForSearchArgs(dimensionName, { + mode, + searchText, + values, + additionalFilter: manager.getOtherDimensionsFilter( + dimensionName, + metricsView, + ), + }), + }, + { + query: { + enabled, + select: (resp) => + resp.data?.map((d) => d[dimensionName] as string) ?? [], + }, + }, + ), + ); + }, + (valuesPerMetricsView) => [ + ...new Set(valuesPerMetricsView.flatMap((values) => values ?? [])), + ], + ); +} + +/** + * Returns the matched search results count. + * + * 1. For Select this will be disabled. + * 2. For InList mode, it returns the count of values actually present in the data source. + * 3. For Contains mode, it returns the count of values matching the search text. + * + * Must be called during component init, once per dimension filter. + */ +export function createDimensionSearchCountQuery( + client: RuntimeClient, + getArgs: () => DimensionSearchArgs, +) { + return createReactiveQueries( + () => { + const { + manager, + dimensionName, + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + } = getArgs(); + const countMeasureName = dimensionName + "__distinct_count"; + + return getMetricsViewsForDimension(manager, dimensionName).map( + (metricsView) => + getQueryServiceMetricsViewAggregationQueryOptions( + client, + { + metricsView, + measures: [ + { + name: countMeasureName, + builtinMeasure: + V1BuiltinMeasure.BUILTIN_MEASURE_COUNT_DISTINCT, + builtinMeasureArgs: [dimensionName], + }, + ], + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + where: getFilterForSearchArgs(dimensionName, { + mode, + searchText, + values, + additionalFilter: manager.getOtherDimensionsFilter( + dimensionName, + metricsView, + ), + }), + }, + { + query: { + enabled, + select: (resp) => + resp.data?.length + ? (resp.data[0][countMeasureName] as number) + : 0, + }, + }, + ), + ); + }, + // Absent while the queries are loading or disabled, so that the chip does not show a count yet. + (countPerMetricsView) => + countPerMetricsView.some((count) => count !== undefined) + ? countPerMetricsView.reduce( + (total, count) => (total ?? 0) + (count ?? 0), + 0, + ) + : undefined, + ); +} + +/** + * Metrics views that define the dimension. A dimension filter only applies to those, and querying a + * metrics view without the dimension errors. + */ +function getMetricsViewsForDimension( + manager: ExpressionFilterManager, + dimensionName: string, +) { + return Object.keys( + manager.metricsViewsProvider.dimensionSpecs[dimensionName] ?? {}, + ); +} + +/** + * Builds the filter for dimension search results or dimension search results count. + * Note the difference, this is for the search results from the search input. + * + * 1. For Select mode, while the final query is an `in` filter, the search results from the search input is a `like` filter. + * 2. For InList mode it is an `in` filter with all the selected values. + * 3. For Contains mode it is a `like` filter. + */ +function getFilterForSearchArgs( + dimensionName: string, + { + mode, + searchText, + values, + additionalFilter, + }: { + mode: DimensionFilterMode; + searchText: string; + values: string[]; + additionalFilter?: V1Expression; + }, +) { + let filter: V1Expression; + if (mode === DimensionFilterMode.InList) { + filter = createInExpression(dimensionName, values); + } else { + const addNull = searchText.length !== 0 && "null".includes(searchText); + filter = addNull + ? createInExpression(dimensionName, [null]) + : createLikeExpression(dimensionName, `%${searchText}%`); + } + + if (additionalFilter) { + return createAndExpression([filter, additionalFilter]); + } + return filter; +} diff --git a/web-common/src/features/dashboards/filters/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 c26e3a789511..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/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/measure-filters-v2/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte new file mode 100644 index 000000000000..5f8d8a037de4 --- /dev/null +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilter.svelte @@ -0,0 +1,161 @@ + + + { + if (open) { + curPinned = pinned; + curRequired = required; + } else { + persistPinnedAndRequired(); + } + }} +> + + {#snippet child({ props })} + + measureManager.clear()} + removable={removable && !pinned && !required} + removeTooltipText={m.dashboard_remove_label({ + label: measureManager.label, + })} + > + { + return d.name === measureManager.dimension; + })?.displayName ?? ""} + {filter} + label={measureManager.label} + slot="body" + /> + +
+ + + {measureManager.name} + {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={yamlConfigProvider.editable} + showRequiredControl={yamlConfigProvider.editable} + {side} + /> + {/if} +
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 new file mode 100644 index 000000000000..133de097c6c8 --- /dev/null +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/MeasureFilterManager.svelte.ts @@ -0,0 +1,83 @@ +import type { V1Expression } from "@rilldata/web-common/runtime-client"; +import { + mapExprToMeasureFilter, + mapMeasureFilterToExpr, + type MeasureFilterEntry, +} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts"; +import { + MeasureFilterOperation, + MeasureFilterType, +} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options.ts"; +import { + createSubQueryExpression, + removeWrapperAndOrExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { convertExpressionToFilterParam } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; + +export class MeasureFilterManager { + public expr: V1Expression | undefined = $state(undefined); + // String representation of the filter expression. Used to check duplicate expressions across metrics views. + public param: string = $state(""); + + public dimension = $state(""); + public operation = $state(MeasureFilterOperation.LessThan); + public type = $state(MeasureFilterType.Value); + public value1 = $state(""); + public value2 = $state(""); + + public constructor( + public readonly name: string, + public readonly label: string, + initExpr: V1Expression | undefined = undefined, + ) { + this.reconcile(initExpr); + } + + public reconcile(expr: V1Expression | undefined) { + const dimension = expr?.subquery?.dimension; + + const unwrappedHavingFilter = removeWrapperAndOrExpression( + expr?.subquery?.having, + ); + const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter); + + this.dimension = dimension ?? ""; + this.operation = + mappedMeasureFilter?.operation ?? MeasureFilterOperation.LessThan; + this.type = mappedMeasureFilter?.type ?? MeasureFilterType.Value; + this.value1 = mappedMeasureFilter?.value1 ?? ""; + this.value2 = mappedMeasureFilter?.value2 ?? ""; + this.commit(); + } + + public setMeasureFilter(dimension: string, newFilter: MeasureFilterEntry) { + this.dimension = dimension; + this.operation = newFilter.operation; + this.type = newFilter.type; + this.value1 = newFilter.value1; + this.value2 = newFilter.value2; + this.commit(); + } + + public clear() { + this.value1 = ""; + this.value2 = ""; + this.dimension = ""; + this.commit(); + } + + public commit() { + const measureFilterExpr = mapMeasureFilterToExpr({ + measure: this.name, + operation: this.operation, + type: this.type, + value1: this.value1, + value2: this.value2, + }); + const hasFilter = Boolean(this.dimension && measureFilterExpr); + this.expr = hasFilter + ? createSubQueryExpression(this.dimension, [this.name], measureFilterExpr) + : undefined; + this.param = this.expr ? convertExpressionToFilterParam(this.expr, []) : ""; + } +} diff --git a/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte b/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte new file mode 100644 index 000000000000..ae8e562a9bd4 --- /dev/null +++ b/web-common/src/features/dashboards/filters/measure-filters-v2/ReadonlyMeasureFilter.svelte @@ -0,0 +1,53 @@ + + + + + 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/filters/utils.ts b/web-common/src/features/dashboards/filters/utils.ts new file mode 100644 index 000000000000..98b43b292e3f --- /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]?.[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 d8b0af4a7f86..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; @@ -35,7 +34,6 @@ measureTooltipFormatters, activeMeasureTooltipFormatter, }, - dimensionFilters: { isFilterExcludeMode }, dimensions: { visibleDimensions }, comparison: { isBeingCompared: isBeingComparedReadable }, sorting: { sortedAscending, sortType }, @@ -49,11 +47,11 @@ actions: { dimensions: { setPrimaryDimension }, sorting: { toggleSort }, - dimensionsFilter: { toggleDimensionValueSelection }, comparison: { toggleComparisonDimension }, }, exploreName, dashboardStore, + expressionFilterManager, } = StateManagers; const client = useRuntimeClient(); @@ -105,13 +103,14 @@ leaderboardMeasures={$leaderboardMeasures} leaderboardShowContextForAllMeasures={$leaderboardShowContextForAllMeasures} {whereFilter} - {dimensionThresholdFilters} {tableWidth} {timeRange} {dimensionColumnWidth} sortedAscending={$sortedAscending} sortType={$sortType} - filterExcludeMode={$isFilterExcludeMode(dimension.name)} + filterExcludeMode={expressionFilterManager.filterManagers.dimensions.find( + (dfm) => dfm.name === dimension.name, + )?.exclude ?? false} {comparisonTimeRange} {dimension} {parentElement} @@ -119,7 +118,7 @@ selectedValues={selectedDimensionValues( client, [metricsViewName], - $dashboardStore.whereFilter, + whereFilter, dimension.name, timeRange.start, timeRange.end, @@ -136,7 +135,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/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts new file mode 100644 index 000000000000..b66a22b2a321 --- /dev/null +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -0,0 +1,72 @@ +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 cleanup: (() => void) | undefined = undefined; + + public constructor(public editable: boolean = false) {} + + public setEditable(newEditable: boolean) { + this.editable = newEditable; + } + + public togglePinnedFilter(filter: string) { + this.pinnedFilters[filter] = !this.pinnedFilters[filter]; + } + + public toggleRequiredFilter(filter: string) { + 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 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/actions/dimension-filters.ts b/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts index 5252f0eadfdd..68352e517415 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/actions/filters.ts b/web-common/src/features/dashboards/state-managers/actions/filters.ts index 32749c48449f..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,5 +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 = @@ -23,6 +28,19 @@ export function setTemporaryFilterName( 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 @@ -30,4 +48,6 @@ export const filterActions = { clearAllFilters, setTemporaryFilterName, + + setFilter, }; diff --git a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte index 14c17a68a992..e8cc303f4e28 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateManager.svelte @@ -22,6 +22,7 @@ import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { onDestroy } from "svelte"; import { clearExploreSessionStore } from "@rilldata/web-common/features/dashboards/state-managers/loaders/explore-web-view-store.ts"; + import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers.ts"; export let exploreName: string; export let storageNamespacePrefix: string | undefined = undefined; @@ -33,6 +34,9 @@ const client = useRuntimeClient(); + const StateManagers = getStateManagers(); + const { expressionFilterManager } = StateManagers; + $: exploreSpecQuery = useExploreValidSpec(client, exploreName); $: exploreSpec = $exploreSpecQuery.data?.explore ?? {}; $: metricsViewName = exploreSpec?.metricsView ?? ""; @@ -56,6 +60,7 @@ exploreName, storageNamespacePrefix, dataLoader, + expressionFilterManager, ); } diff --git a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts index 7b1c3e3e1240..4a7e869198ce 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,8 @@ import type { AfterNavigate } from "@sveltejs/kit"; import { getContext, setContext } from "svelte"; import { derived, get, type Readable } from "svelte/store"; import type { CompoundQueryResult } from "@rilldata/web-common/features/compound-query-result"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; +import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); @@ -54,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( @@ -81,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); } @@ -185,6 +198,8 @@ export class DashboardStateSync { ); } + log("INIT", redirectUrl); + this.expressionFilterManager.setUrlParams(redirectUrl.searchParams); // If the current url same as the new url then there is no need to do anything if (redirectUrl.search === pageState.url.search) { this.initialized = true; @@ -262,6 +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); @@ -294,6 +310,8 @@ export class DashboardStateSync { this.updating = false; } + log("URL", redirectUrl); + this.expressionFilterManager.setUrlParams(redirectUrl.searchParams); // If the url doesn't need to be changed further then we can skip the goto if (redirectUrl.search === pageState.url.search) { return; @@ -349,6 +367,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 +381,12 @@ export class DashboardStateSync { } } } + +function log(label: string, toUrl: URL) { + const fromUrlSearch = get(page).url.search; + const toUrlSearch = toUrl.search; + const equal = fromUrlSearch === toUrlSearch; + console.log( + `[${label}] ${fromUrlSearch} =${equal ? "X" : "="}> ${toUrlSearch}`, + ); +} diff --git a/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts b/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts index 407988549b7a..bb11ee660050 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/dashboard-queries.ts @@ -1,16 +1,14 @@ import { additionalMeasures } from "../../selectors"; -import type { DimensionThresholdFilter } from "web-common/src/features/dashboards/stores/explore-state"; +import type { V1Expression } from "@rilldata/web-common/runtime-client"; export function getMeasuresForDimensionOrLeaderboardDisplay( sortByMeasureName: string | null, - dimensionThresholdFilters: DimensionThresholdFilter[], + expr: V1Expression | undefined, visibleMeasureNames: string[], ) { const allMeasures = new Set([ ...visibleMeasureNames, - ...(sortByMeasureName - ? additionalMeasures(sortByMeasureName, dimensionThresholdFilters) - : []), + ...(sortByMeasureName ? additionalMeasures(sortByMeasureName, expr) : []), ]); return [...allMeasures]; } diff --git a/web-common/src/features/dashboards/state-managers/selectors/dimension-filters.ts b/web-common/src/features/dashboards/state-managers/selectors/dimension-filters.ts index 94fd870222cd..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/selectors/measures.ts b/web-common/src/features/dashboards/state-managers/selectors/measures.ts index 031ed78b1039..5be9c1cc60a8 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/measures.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/measures.ts @@ -75,24 +75,30 @@ export const filteredSimpleMeasures = ({ validMetricsView, validExplore, }: DashboardDataSources) => { - return () => { - if (!validMetricsView?.measures || !validExplore?.measures) return []; - - return ( - validMetricsView.measures - .filter( - (m) => validExplore.measures!.includes(m.name!) && isSimpleMeasure(m), - ) - // Sort the filtered measures based on their order in validExplore.measures - .sort( - (a, b) => - validExplore.measures!.indexOf(a.name!) - - validExplore.measures!.indexOf(b.name!), - ) + return () => + getFilteredSimpleMeasures( + validMetricsView?.measures ?? [], + validExplore?.measures, ); - }; }; +export function getFilteredSimpleMeasures( + allMeasures: MetricsViewSpecMeasure[], + exploreMeasures: string[] | undefined, +) { + if (!exploreMeasures) return []; + + return ( + allMeasures + .filter((m) => exploreMeasures.includes(m.name!) && isSimpleMeasure(m)) + // Sort the filtered measures based on their order in validExplore.measures + .sort( + (a, b) => + exploreMeasures.indexOf(a.name!) - exploreMeasures.indexOf(b.name!), + ) + ); +} + export const isSimpleMeasure = (measure: MetricsViewSpecMeasure) => !measure.window && measure.type !== MetricsViewSpecMeasureType.MEASURE_TYPE_TIME_COMPARISON; diff --git a/web-common/src/features/dashboards/state-managers/state-managers.ts b/web-common/src/features/dashboards/state-managers/state-managers.ts index f9f9036cd15b..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,6 +33,9 @@ import { contextColWidthDefaults, 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"; export type StateManagers = { runtimeClient: RuntimeClient; @@ -65,6 +68,8 @@ export type StateManagers = { */ contextColumnWidths: Writable; defaultExploreState: Readable; + expressionFilterManager: ExpressionFilterManager; + cleanup: () => void; }; export const DEFAULT_STORE_KEY = Symbol("state-managers"); @@ -163,6 +168,14 @@ export function createStateManagers({ }, ); + const metricsViewProvider = new MetricsViewsProvider(runtimeClient, [ + metricsViewName, + ]); + const expressionFilterManager = new ExpressionFilterManager( + metricsViewProvider, + new YAMLConfigProvider(), + ); + return { runtimeClient, metricsViewName: metricsViewNameStore, @@ -191,5 +204,9 @@ export function createStateManagers({ }), contextColumnWidths, defaultExploreState, + 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/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 f86914ae0087..4b23e13d9ca1 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"; @@ -403,6 +403,14 @@ export function removeWrapperAndOrExpression( return expression.cond?.exprs?.[0]; } +export function maybeWrapAndExpression(expr: V1Expression | undefined) { + const needsWrapper = + !!expr && + expr.cond?.op !== V1Operation.OPERATION_AND && + expr.cond?.op !== V1Operation.OPERATION_OR; + return needsWrapper ? createAndExpression([expr]) : expr; +} + const SupportedOperations = new Set([ V1Operation.OPERATION_IN, V1Operation.OPERATION_NIN, @@ -410,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 || @@ -530,3 +539,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/dashboards/time-dimension-details/TDDHeader.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte index ba844e368db1..40beb8d90a3e 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.filterManagers.dimensions.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 ae6095da00c6..b82dcf2d88b5 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.filterManagers.dimensions.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..c906a038a327 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.filterManagers.dimensions.find( + (dfm) => dfm.name === comparisonDimension, + )?.selectedValues ?? []; $: chartDimensionValues = includedValuesForDimension.slice( 0, showTimeDimensionDetail ? 11 : 7, @@ -482,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 d7219f5dd859..da110bb68b02 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/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 @@ - import CanvasFilterChipsReadOnly from "@rilldata/web-common/features/dashboards/filters/CanvasFilterChipsReadOnly.svelte"; import { prettyFormatTimeRange } from "@rilldata/web-common/lib/time/ranges/formatter"; import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; + import ReadonlyExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ReadonlyExpressionFilters.svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; // Off-screen, read-only render of the canvas's active time range and filters. // It mirrors the explore "Download as PNG" summary so the PDF capture shows a // static, undistorted filter-bar summary instead of the live interactive bar. // The dashboard title and timestamp are drawn as vector text in assemble.ts. - export let canvasName: string; - export let instanceId: string; - export let maxWidth: number; + let { + canvasName, + instanceId, + maxWidth, + }: { canvasName: string; instanceId: string; maxWidth: number } = $props(); - $: ({ + let { canvasEntity: { timeManager: { state: { @@ -23,37 +25,20 @@ showTimeComparisonStore, }, }, - filterManager: { activeUIFiltersStore }, + expressionFilterManager, }, - } = getCanvasStore(canvasName, instanceId)); + } = $derived(getCanvasStore(canvasName, instanceId)); - $: grain = $grainStore; + let grain = $derived($grainStore); // Exact, resolved range (e.g. "Jan 1 – Jan 7, 2024"), never the relative alias. - $: formattedTimeRange = $intervalStore - ? prettyFormatTimeRange($intervalStore, grain) - : ""; - $: formattedComparisonRange = + let formattedTimeRange = $derived( + $intervalStore ? prettyFormatTimeRange($intervalStore, grain) : "", + ); + let formattedComparisonRange = $derived( $showTimeComparisonStore && $comparisonIntervalStore ? prettyFormatTimeRange($comparisonIntervalStore, grain) - : ""; - - // Drop pinned-but-empty filters (interactive affordances with no applied - // value); a static PDF should only show filters that actually constrain data. - $: uiFilters = { - ...$activeUIFiltersStore, - dimensionFilters: new Map( - [...$activeUIFiltersStore.dimensionFilters].filter( - ([, f]) => - (f.selectedValues?.length ?? 0) > 0 || - (!!f.inputText && f.inputText.length > 0), - ), - ), - measureFilters: new Map( - [...$activeUIFiltersStore.measureFilters].filter(([, f]) => !!f.filter), - ), - }; - $: hasFilters = - uiFilters.dimensionFilters.size > 0 || uiFilters.measureFilters.size > 0; + : "", + );
{/if} - {#if hasFilters} - + {#if expressionFilterManager.hasSomeFilter} + {/if}
diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts new file mode 100644 index 000000000000..2578d60d17af --- /dev/null +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.spec.ts @@ -0,0 +1,80 @@ +import { + AD_BIDS_BID_PRICE_MEASURE, + AD_BIDS_DOMAIN_DIMENSION, + AD_BIDS_IMPRESSIONS_MEASURE, + AD_BIDS_METRICS_INIT, + AD_BIDS_METRICS_NAME, + AD_BIDS_PUBLISHER_DIMENSION, +} from "@rilldata/web-common/features/dashboards/stores/test-data/data"; +import { + createTestMetricsViewsProvider, + useMetricsViewMocks, +} from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts"; +import { describe, expect, it } from "vitest"; + +const AD_BIDS_MIRROR_METRICS_NAME = "AdBids_mirror_metrics"; + +useMetricsViewMocks({ + [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT, + // Shares the publisher dimension and the impressions measure with AdBids. + [AD_BIDS_MIRROR_METRICS_NAME]: { + ...AD_BIDS_METRICS_INIT, + dimensions: [{ name: AD_BIDS_PUBLISHER_DIMENSION }], + measures: [{ name: AD_BIDS_IMPRESSIONS_MEASURE }], + }, +}); + +describe("MetricsViewsProvider", () => { + it("loads the spec for each metrics view", async () => { + const { value: provider, destroy } = await createTestMetricsViewsProvider([ + AD_BIDS_METRICS_NAME, + ]); + + // Not an exact match: the transport fills in proto default values. + expect(provider.specs[AD_BIDS_METRICS_NAME]).toMatchObject( + AD_BIDS_METRICS_INIT, + ); + expect(provider.measures.map((m) => m.name)).toEqual([ + AD_BIDS_IMPRESSIONS_MEASURE, + AD_BIDS_BID_PRICE_MEASURE, + ]); + expect(provider.dimensions.map((d) => d.name)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + AD_BIDS_DOMAIN_DIMENSION, + ]); + // No time dimension, so there is no time range summary to wait for. + expect(provider.ready).toBe(true); + + destroy(); + }); + + it("maps a shared measure or dimension to every metrics view defining it", async () => { + const { value: provider, destroy } = await createTestMetricsViewsProvider([ + AD_BIDS_METRICS_NAME, + AD_BIDS_MIRROR_METRICS_NAME, + ]); + + expect( + Object.keys(provider.dimensionSpecs[AD_BIDS_PUBLISHER_DIMENSION]), + ).toEqual([AD_BIDS_METRICS_NAME, AD_BIDS_MIRROR_METRICS_NAME]); + expect( + Object.keys(provider.measureSpecs[AD_BIDS_IMPRESSIONS_MEASURE]), + ).toEqual([AD_BIDS_METRICS_NAME, AD_BIDS_MIRROR_METRICS_NAME]); + + // Only AdBids defines these, so they stay single-entry. + expect( + Object.keys(provider.dimensionSpecs[AD_BIDS_DOMAIN_DIMENSION]), + ).toEqual([AD_BIDS_METRICS_NAME]); + expect( + Object.keys(provider.measureSpecs[AD_BIDS_BID_PRICE_MEASURE]), + ).toEqual([AD_BIDS_METRICS_NAME]); + + // Deduped across metrics views, first definition wins. + expect(provider.dimensions.map((d) => d.name)).toEqual([ + AD_BIDS_PUBLISHER_DIMENSION, + AD_BIDS_DOMAIN_DIMENSION, + ]); + + destroy(); + }); +}); diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts new file mode 100644 index 000000000000..e7cc264dcd31 --- /dev/null +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -0,0 +1,247 @@ +import { + createQueryServiceMetricsViewTimeRange, + createRuntimeServiceListResources, + type MetricsViewSpecDimension, + type MetricsViewSpecMeasure, + type V1MetricsViewSpec, + type V1Resource, + type V1TimeRangeSummary, +} from "@rilldata/web-common/runtime-client"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { isSimpleMeasure } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures.ts"; +import { Duration } from "luxon"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; + +export type MetricsViewName = string; +export type DimensionName = string; +export type MeasureName = string; + +/** + * Reactive view over a set of metrics views. + * + * Specs for every metrics view come from a single ListResources subscription. + * Time range summaries are fetched per metrics view, and only for the ones that have a time dimension, + * so the summaries arrive after the specs rather than alongside them. + * + * Measures and dimensions are exposed two ways: + * as deduped flat lists for pickers, and as name -> metrics view -> spec maps for callers that need to + * know which metrics views a given measure or dimension belongs to. + */ +export class MetricsViewsProvider { + /** Valid spec per metrics view name. Absent while the resource is loading or invalid. */ + public specs = $state>({}); + /** Time range summary per metrics view name. Absent for metrics views without a time dimension. */ + public timeRangeSummaries = $state< + Record + >({}); + /** Max queryable time range in milliseconds per metrics view name. Zero when unrestricted. */ + public maxQueryTimeRangeMillis = $state>({}); + + /** Dimension spec per metrics view, keyed by dimension name (or column when unnamed). */ + public dimensionSpecs = $state< + Record> + >({}); + /** + * Measure spec per metrics view, keyed by measure name. + * The same measure name can be defined by more than one metrics view. + */ + public measureSpecs = $state< + Record> + >({}); + + /** Deduped by name across metrics views; the first metrics view to define a name wins. */ + public measures = $state([]); + public simpleMeasures = $state([]); + public dimensions = $state([]); + + /** Union of the individual summaries: earliest min, latest max, latest watermark. */ + public timeRangeSummary: V1TimeRangeSummary | undefined; + /** Smallest restriction across the metrics views, since it has to hold for all of them. */ + public maxQueryTimeRange: Duration | undefined; + /** True once every metrics view has a spec and every time series metrics view has a summary. */ + public ready: boolean; + public metricsViewNames = $state([]); + + public cleanup: () => void; // TODO: ensure this is called by creators + + private resources: V1Resource[] = []; + private readonly timeRangeUnsubs = new Map void>(); + + public constructor( + public readonly runtimeClient: RuntimeClient, + initMetricsViewNames: string[], + ) { + this.metricsViewNames = initMetricsViewNames; + + const allResourcesQuery = createRuntimeServiceListResources( + runtimeClient, + {}, + undefined, + queryClient, + ); + const allResourcesUnsub = allResourcesQuery.subscribe( + (allResourcesResp) => { + this.resources = allResourcesResp.data?.resources ?? []; + this.processResources(); + }, + ); + + this.timeRangeSummary = $derived.by(() => { + let min: string | undefined; + let max: string | undefined; + let watermark: string | undefined; + let minTime = Infinity; + let maxTime = -Infinity; + let watermarkTime = -Infinity; + + for (const metricsViewName of this.metricsViewNames) { + const summary = this.timeRangeSummaries[metricsViewName]; + if (!summary) continue; + + // Date.parse returns NaN for missing or malformed timestamps, + // and every comparison against NaN is false, so those simply never win. + const minCandidate = Date.parse(summary.min ?? ""); + if (minCandidate < minTime) { + minTime = minCandidate; + min = summary.min; + } + + const maxCandidate = Date.parse(summary.max ?? ""); + if (maxCandidate > maxTime) { + maxTime = maxCandidate; + max = summary.max; + } + + const watermarkCandidate = Date.parse(summary.watermark ?? ""); + if (watermarkCandidate > watermarkTime) { + watermarkTime = watermarkCandidate; + watermark = summary.watermark; + } + } + + if (!min && !max && !watermark) return undefined; + return { min, max, watermark }; + }); + + this.maxQueryTimeRange = $derived.by(() => { + let smallestMillis = Infinity; + for (const metricsViewName of this.metricsViewNames) { + const millis = this.maxQueryTimeRangeMillis[metricsViewName] ?? 0; + if (millis > 0 && millis < smallestMillis) smallestMillis = millis; + } + return smallestMillis === Infinity + ? undefined + : Duration.fromMillis(smallestMillis); + }); + + this.ready = $derived( + this.metricsViewNames.every((metricsViewName) => { + const spec = this.specs[metricsViewName]; + if (!spec) return false; + return ( + !spec.timeDimension || !!this.timeRangeSummaries[metricsViewName] + ); + }), + ); + + this.cleanup = () => { + allResourcesUnsub(); + this.timeRangeUnsubs.forEach((unsub) => unsub()); + this.timeRangeUnsubs.clear(); + }; + } + + public setMetricsViewNames(metricsViewNames: string[]) { + this.metricsViewNames = metricsViewNames; + this.processResources(); + } + + private processResources() { + const specs: Record = {}; + + const measureSpecs: Record< + string, + Record + > = {}; + const measures: MetricsViewSpecMeasure[] = []; + const simpleMeasures: MetricsViewSpecMeasure[] = []; + + const dimensionSpecs: Record< + string, + Record + > = {}; + const dimensions: MetricsViewSpecDimension[] = []; + + for (const metricsViewName of this.metricsViewNames) { + const spec = this.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 }, + undefined, + queryClient, + ); + this.timeRangeUnsubs.set( + metricsViewName, + timeRangeQuery.subscribe((timeRangeResp) => { + const summary = timeRangeResp.data?.timeRangeSummary; + if (summary) this.timeRangeSummaries[metricsViewName] = summary; + this.maxQueryTimeRangeMillis[metricsViewName] = Number( + timeRangeResp.data?.maxQueryTimeRangeMillis ?? 0, + ); + }), + ); + } +} diff --git a/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte b/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte new file mode 100644 index 000000000000..a9e22a5643d9 --- /dev/null +++ b/web-common/src/features/metrics-views/providers/test/RuntimeContextHarness.svelte @@ -0,0 +1,34 @@ + diff --git a/web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts b/web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts new file mode 100644 index 000000000000..1231e4922c67 --- /dev/null +++ b/web-common/src/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts @@ -0,0 +1,156 @@ +import { DashboardFetchMocks } from "@rilldata/web-common/features/dashboards/dashboard-fetch-mocks"; +import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import RuntimeContextHarness from "@rilldata/web-common/features/metrics-views/providers/test/RuntimeContextHarness.svelte"; +import { waitUntil } from "@rilldata/web-common/lib/waitUtils"; +import type { V1MetricsViewSpec } from "@rilldata/web-common/runtime-client"; +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { QueryClient } from "@tanstack/svelte-query"; +import { mount, unmount } from "svelte"; + +/** + * Helpers for unit tests that need real metrics view specs rather than hand-stubbed ones. + * + * useMetricsViewMocks({ [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT }); + * + * it("filters by publisher", async () => { + * const { value: provider, destroy } = await createTestMetricsViewsProvider([ + * AD_BIDS_METRICS_NAME, + * ]); + * ... + * destroy(); + * }); + */ + +export type MetricsViewSpecs = Record; + +export type RuntimeTestContext = { + runtimeClient: RuntimeClient; + queryClient: QueryClient; +}; + +export type RenderedInRuntimeContext = RuntimeTestContext & { + /** Whatever `init` returned. */ + value: T; + destroy: () => void; +}; + +/** + * Serves `specs` from ListResources for the duration of the test file. + * + * Call this at module or `describe` scope, since it registers the `beforeAll` hook + * that stubs `fetch`. The returned mocks can be used to add responses for other + * endpoints, such as aggregation queries backing dimension value lists. + */ +export function useMetricsViewMocks(specs: MetricsViewSpecs) { + const mocks = DashboardFetchMocks.useDashboardFetchMocks(); + for (const [metricsViewName, spec] of Object.entries(specs)) { + mocks.mockMetricsView(metricsViewName, spec); + } + return mocks; +} + +/** + * Runs `init` inside an effect root, which is all a class needs when it only calls + * `$effect` in its constructor, such as ExpressionFilterManager. Classes that also + * call `createQuery` need a QueryClient context, so use {@link renderInRuntimeContext} + * for those. + */ +export function createInEffectRoot(init: () => T): { + value: T; + destroy: () => void; +} { + let value: T | undefined; + const destroy = $effect.root(() => { + value = init(); + }); + return { value: value as T, destroy }; +} + +/** + * Runs `init` inside a mounted component so that it has a QueryClient context and an + * effect owner. Classes like MetricsViewsProvider call `createQuery` in their + * constructors, so they can only be built here and not directly from a test body. + * + * The caller owns the returned `destroy`; call it once the test is done to unmount + * the harness and stop the queries. + */ +export function renderInRuntimeContext( + init: (ctx: RuntimeTestContext) => T, +): RenderedInRuntimeContext { + const runtimeClient = new RuntimeClient({ + host: "http://localhost", + instanceId: "test", + }); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + networkMode: "always", + }, + }, + }); + + let value: T | undefined; + const component = mount(RuntimeContextHarness, { + target: document.createElement("div"), + props: { + queryClient, + runtimeClient, + init: () => { + value = init({ runtimeClient, queryClient }); + }, + }, + }); + + return { + value: value as T, + runtimeClient, + queryClient, + destroy: () => { + void unmount(component); + queryClient.clear(); + runtimeClient.dispose(); + }, + }; +} + +/** + * Creates a MetricsViewsProvider for `metricsViewNames` and resolves once the specs + * mocked by {@link useMetricsViewMocks} have landed. + */ +export async function createTestMetricsViewsProvider( + metricsViewNames: string[], +): Promise> { + const rendered = renderInRuntimeContext(({ runtimeClient }) => { + return new MetricsViewsProvider(runtimeClient, metricsViewNames); + }); + + await waitForMetricsViewSpecs(rendered.value, metricsViewNames); + + return rendered; +} + +/** Resolves once every requested metrics view has a spec, or throws on timeout. */ +export async function waitForMetricsViewSpecs( + metricsViewsProvider: MetricsViewsProvider, + metricsViewNames: string[], + timeout = 5000, +) { + const loaded = await waitUntil( + () => metricsViewNames.every((name) => !!metricsViewsProvider.specs[name]), + timeout, + 10, + ); + if (!loaded) { + const missing = metricsViewNames.filter( + (name) => !metricsViewsProvider.specs[name], + ); + throw new Error( + `Timed out waiting for metrics view specs: ${missing.join(", ")}. ` + + `Did the test call useMetricsViewMocks with these metrics views?`, + ); + } +} diff --git a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte index 994737ec28c9..209186a0bc6d 100644 --- a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte +++ b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte @@ -5,7 +5,6 @@ import MultiInput from "@rilldata/web-common/components/forms/MultiInput.svelte"; import FormSection from "@rilldata/web-common/components/forms/FormSection.svelte"; import { getHasSlackConnection } from "@rilldata/web-common/features/alerts/delivery-tab/notifiers-utils"; - import type { Filters } from "@rilldata/web-common/features/dashboards/stores/Filters.ts"; import type { TimeControls } from "@rilldata/web-common/features/dashboards/stores/TimeControls.ts"; import FiltersForm from "@rilldata/web-common/features/scheduled-reports/FiltersForm.svelte"; import RowsAndColumnsForm from "@rilldata/web-common/features/scheduled-reports/fields/RowsAndColumnsForm.svelte"; @@ -24,6 +23,7 @@ import Select from "../../components/forms/Select.svelte"; import Checkbox from "../../components/forms/Checkbox.svelte"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; + import type { ExpressionFilterManager } from "../dashboards/filters/ExpressionFilterManager.svelte.ts"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts"; import { ResourceKind, @@ -39,13 +39,13 @@ export let errors: SuperFormErrors; export let submit: () => void; export let enhance; - // Exactly one of exploreName and canvasName is non-empty; canvasName selects the canvas PDF variant of the form. + export let metricsViewName: string; export let exploreName: string; export let canvasName: string = ""; // Canvas state (URL search string) to display instead of the page URL; set when // editing a report so the filter bar shows the report's captured state. export let canvasStateOverride: string | undefined = undefined; - export let filters: Filters | undefined = undefined; + export let filters: ExpressionFilterManager | undefined = undefined; export let timeControls: TimeControls | undefined = undefined; const RUN_AS_OPTIONS = [ @@ -245,7 +245,13 @@ id="filters" capitalize={false} /> - +
{/if} diff --git a/web-common/src/features/scheduled-reports/FiltersForm.svelte b/web-common/src/features/scheduled-reports/FiltersForm.svelte index 3aa46b4e2261..22c340e24b4e 100644 --- a/web-common/src/features/scheduled-reports/FiltersForm.svelte +++ b/web-common/src/features/scheduled-reports/FiltersForm.svelte @@ -1,18 +1,9 @@