diff --git a/web-common/src/features/alerts/AlertForm.svelte b/web-common/src/features/alerts/AlertForm.svelte index 76f962c202d0..2ff658deca6e 100644 --- a/web-common/src/features/alerts/AlertForm.svelte +++ b/web-common/src/features/alerts/AlertForm.svelte @@ -185,7 +185,7 @@ queryArgsJson: JSON.stringify( getAlertQueryArgsFromFormValues( values, - filters.toState(), + filters.expr, timeControls.toState(), exploreSpec, ), @@ -324,18 +324,18 @@
{#if currentTabIndex === 0} - + {:else} - + {/if} {#if currentTabIndex !== 2} - + {:else} - {/if} - {/if} -
- + diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte index c26e3a789511..87fe704e2545 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilter.svelte @@ -303,8 +303,8 @@ } } - function handleToggleExcludeMode(checked: boolean) { - curExcludeMode = checked; + function handleToggleExcludeMode() { + curExcludeMode = !curExcludeMode; excludeModeDirty = true; } diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte index bd6e59d9ffd7..1b82318efb09 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte +++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterFooter.svelte @@ -9,7 +9,7 @@ export let excludeMode: boolean; export let allSelected: boolean; export let disableApplyButton: boolean; - export let onToggleExcludeMode: (checked: boolean) => void; + export let onToggleExcludeMode: () => void; export let onToggleSelectAll: () => void; export let onApply: () => void; diff --git a/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte new file mode 100644 index 000000000000..19a6719fa696 --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/DimensionFilter.svelte @@ -0,0 +1,522 @@ + + + { + if (e.key === "Enter") onApply(); + }} +/> + + + + {#snippet child({ props })} + + dimensionManager.clear()} + removable={!readOnly && + !proxyDimensionManager.pinned && + !dimensionManager.pinned} + {readOnly} + removeTooltipText="remove {dimensionManager.selectedValues + .length} value{dimensionManager.selectedValues.length !== 1 + ? 's' + : ''}" + > + + +
+ + + {dimensionManager.name} + {dimensionManager.pinned + ? "required dimension" + : "dimension"} + + {#if missingRequired} + This filter is required. Select a value to load the dashboard. + {:else} + Click to edit the filters in this dimension + {/if} + +
+
+ {/snippet} +
+ + + +
+ {#if dimensionManager.editing} +
+ {dimensionManager.label} + +
+ proxyDimensionManager.toggleRequired()} + /> + proxyDimensionManager.togglePinned()} + /> +
+
+ {/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 correctedSearchResults} + + + {#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/manager/ExpressionFilters.svelte b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte new file mode 100644 index 000000000000..66befd97775b --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/ExpressionFilters.svelte @@ -0,0 +1,132 @@ + + +
+
+ {#if !hasFilters} +
+ {m.dashboard_no_filters_selected()} +
+ {:else} + {#each expressionFilterManager.dimensionFilterManagers as dimensionManager (dimensionManager.name)} + + isUrlTooLongAfterInListFilter(dimensionManager.name, values) + : undefined} + /> + {/each} + + {#each expressionFilterManager.measureFilterManagers as measureManager (measureManager.name)} + + {/each} + {/if} + + + + {#if hasFilters} + + {/if} +
+
diff --git a/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte new file mode 100644 index 000000000000..2ba1aafdf36d --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/MeasureFilter.svelte @@ -0,0 +1,147 @@ + + + { + if (!open) return; + if (measureManager.pinned !== curPinned) { + measureManager.togglePinned(); + } + if (measureManager.required !== curRequired) { + measureManager.toggleRequired(); + } + }} +> + + {#snippet child({ props })} + + measureManager.clear()} + removable={!curPinned && !measureManager.required} + removeTooltipText={m.dashboard_remove_label({ + label: measureManager.label, + })} + > + { + return d.name === measureManager.dimension; + })?.displayName ?? ""} + {filter} + label={measureManager.label} + slot="body" + /> + +
+ + + {measureManager.name} + {measureManager.required + ? m.dashboard_required_measure() + : measureManager.label || ""} + + + {#if missingRequired} + {m.dashboard_filter_required_set_value()} + {:else} + + {m.dashboard_click_to_edit_values()} + + {/if} + +
+
+ {/snippet} +
+ + {#if open} + onApply(dimension, filter)} + bind:pinned={curPinned} + bind:required={curRequired} + showPinControl={measureManager.editing} + showRequiredControl={measureManager.editing} + {side} + /> + {/if} +
diff --git a/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts new file mode 100644 index 000000000000..21b187cd163c --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts @@ -0,0 +1,186 @@ +import { page } from "$app/state"; +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; +import { + type MetricsViewSpecDimension, + 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"; + +export class DimensionFilterManager { + public expr: V1Expression | undefined = $state(undefined); + + public mode: DimensionFilterMode; + public selectedValues: string[]; + public inputText: string; + public exclude: boolean; + public pinned: boolean; + public required: boolean; + + private oldMode: DimensionFilterMode; + + public constructor( + public readonly name: string, + public readonly label: string, + public readonly dimensions: Map, + public readonly editing: boolean, // TODO: maybe separate editing, pinned & required? + initExpr: V1Expression = createInExpression(name, []), + isInList: boolean = false, + ) { + let initMode: DimensionFilterMode = DimensionFilterMode.Select; + let initSelectedValues: string[] = []; + let initInputText: string = ""; + let initExclude: boolean = false; + + const op = initExpr.cond?.op; + if (op === V1Operation.OPERATION_IN || op === V1Operation.OPERATION_NIN) { + initMode = isInList + ? DimensionFilterMode.InList + : DimensionFilterMode.Select; + initSelectedValues = getValuesInExpression(initExpr); + initExclude = op === V1Operation.OPERATION_NIN; + } else if ( + op === V1Operation.OPERATION_LIKE || + op === V1Operation.OPERATION_NLIKE + ) { + initMode = DimensionFilterMode.Contains; + initInputText = sanitizeSearchText( + initExpr.cond?.exprs?.[1]?.val?.toString?.() ?? "", + ); + initExclude = op === V1Operation.OPERATION_NLIKE; + } + + this.mode = $state(initMode); + this.oldMode = initMode; + this.selectedValues = $state(initSelectedValues); + this.inputText = $state(initInputText); + this.exclude = $state(initExclude); + this.pinned = $state(false); + this.required = $state(false); + this.commit(); + } + + public clone() { + return new DimensionFilterManager( + this.name, + this.label, + this.dimensions, + this.editing, + this.expr, + this.mode === DimensionFilterMode.InList, + ); + } + + 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 togglePinned() { + this.pinned = !this.pinned; + } + + public toggleRequired() { + this.required = !this.required; + } + + public clear() { + this.selectedValues = []; + this.inputText = ""; + this.expr = undefined; + this.commit(); + } + + public commit() { + switch (this.mode) { + case DimensionFilterMode.Select: + if (this.oldMode !== DimensionFilterMode.Select) { + eventBus.emit("notification", { + message: "Converted filter type to Select", + link: { + text: m.common_undo(), + href: page.url.href, + }, + }); + } + // eslint-disable-next-line no-fallthrough + case DimensionFilterMode.InList: + this.expr = this.selectedValues.length + ? createInExpression(this.name, this.selectedValues, this.exclude) + : undefined; + break; + + case DimensionFilterMode.Contains: + this.expr = this.inputText + ? createLikeExpression(this.name, `%${this.inputText}%`, this.exclude) + : undefined; + break; + } + this.oldMode = this.mode; + } +} + +export function sanitizeSearchText(searchText: string) { + return searchText.replace(/^%/, "").replace(/%$/, ""); +} diff --git a/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts new file mode 100644 index 000000000000..f79444cb117c --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/expression-filter-manager.svelte.ts @@ -0,0 +1,217 @@ +import { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; +import type { V1Expression } from "@rilldata/web-admin/client"; +import { convertFilterParamToExpression } from "@rilldata/web-common/features/dashboards/url-state/filters/converters.ts"; +import { + createAndExpression, + forEachIdentifier, + isExpressionUnsupported, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { + type MetricsViewSpecDimension, + type MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client"; +import { + getDimensionDisplayName, + getMeasureDisplayName, +} from "@rilldata/web-common/features/dashboards/filters/getDisplayName.ts"; +import { MeasureFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/measure-filter-manager.svelte.ts"; + +export class ExpressionFilterManager { + public measureFilterManagers: MeasureFilterManager[] = $state([]); + public dimensionFilterManagers: DimensionFilterManager[] = $state([]); + public temporaryFilter: + | MeasureFilterManager + | DimensionFilterManager + | undefined = $state(undefined); + + public expr: V1Expression; + public exprParam: string = ""; + public isComplexFilter: boolean = $state(false); + + public exploreName: string = ""; + public metricsViewName: string = ""; + public measureIdMap: Map = new Map(); + public dimensionIdMap: Map = new Map(); + + public constructor() { + this.expr = $derived.by(() => { + const dimExprs = this.dimensionFilterManagers + .filter((dfm) => !!dfm.expr) + .map((d) => d.expr as V1Expression); + const mesExprs = this.measureFilterManagers + .filter((mfm) => !!mfm.expr) + .map((m) => m.expr as V1Expression); + return createAndExpression(dimExprs.concat(mesExprs)); + }); + } + + public syncSpec( + metricsViewName: string, + exploreName: string, + measureIdMap: Map, + dimensionIdMap: Map, + ) { + this.metricsViewName = metricsViewName; + this.exploreName = exploreName; + this.measureIdMap = measureIdMap; + this.dimensionIdMap = dimensionIdMap; + } + + public setExprParam(exprParam: string) { + if (exprParam === this.exprParam) return; + this.exprParam = exprParam; + + const { expr, dimensionsWithInlistFilter } = + convertFilterParamToExpression(exprParam); + this.isComplexFilter = expr ? isExpressionUnsupported(expr) : false; + if (!expr || this.isComplexFilter) { + this.dimensionFilterManagers = []; + this.measureFilterManagers = []; + return; + } + + const addedMeasure = new Set(); + const newMeasureFilterManagers: MeasureFilterManager[] = []; + + const addedDimension = new Set(); + const newDimensionFilterManagers: DimensionFilterManager[] = []; + + forEachIdentifier(expr, (e, ident) => { + const dim = this.dimensionIdMap.get(ident); + if (!dim) return; + + const firstValueExpr = e?.cond?.exprs?.[1]; + + if (firstValueExpr?.subquery) { + const measureName = firstValueExpr.subquery.measures?.[0]; + if (!measureName) return; + + const measure = this.measureIdMap.get(measureName); + if (!measure) return; + + addedMeasure.add(measureName); + + newMeasureFilterManagers.push( + new MeasureFilterManager( + measureName, + getMeasureDisplayName(measure), + new Map([ + [this.metricsViewName, measure], + ]), + false, + ident, + firstValueExpr, + ), + ); + } else { + addedDimension.add(ident); + + const isInListMode = dimensionsWithInlistFilter.includes(ident); + newDimensionFilterManagers.push( + new DimensionFilterManager( + ident, + getDimensionDisplayName(dim), + new Map([ + [this.metricsViewName, dim], + ]), + false, + e, + isInListMode, + ), + ); + } + }); + + if (this.temporaryFilter instanceof MeasureFilterManager) { + if (!addedMeasure.has(this.temporaryFilter.name)) { + newMeasureFilterManagers.push(this.temporaryFilter); + } else { + this.temporaryFilter = undefined; + } + } else if (this.temporaryFilter instanceof DimensionFilterManager) { + if (!addedDimension.has(this.temporaryFilter.name)) { + newDimensionFilterManagers.push(this.temporaryFilter); + } else { + this.temporaryFilter = undefined; + } + } + + this.measureFilterManagers = newMeasureFilterManagers; + this.dimensionFilterManagers = newDimensionFilterManagers; + } + + public addTemporaryMeasureFilter(name: string) { + const measure = this.measureIdMap.get(name); + if (!measure) return; + + this.temporaryFilter = new MeasureFilterManager( + name, + getMeasureDisplayName(measure), + new Map([ + [this.metricsViewName, measure], + ]), + false, + ); + this.measureFilterManagers = [ + ...this.measureFilterManagers, + this.temporaryFilter, + ]; + } + + public addTemporaryDimensionFilter(name: string) { + const dim = this.dimensionIdMap.get(name); + if (!dim) return; + + this.temporaryFilter = new DimensionFilterManager( + name, + getDimensionDisplayName(dim), + new Map([[this.metricsViewName, dim]]), + false, + ); + this.dimensionFilterManagers = [ + ...this.dimensionFilterManagers, + this.temporaryFilter, + ]; + } + + public dimensionFilterAction( + name: string, + callback: (dimensionFilterManager: DimensionFilterManager) => any, + ) { + const dim = this.dimensionIdMap.get(name); + if (!dim) return; + + const existingManager = this.dimensionFilterManagers.find( + (dfm) => dfm.name === name, + ); + const dimensionFilterManager = + existingManager ?? + new DimensionFilterManager( + name, + getDimensionDisplayName(dim), + new Map([ + [this.metricsViewName, dim], + ]), + false, + ); + + const ret = callback(dimensionFilterManager); + if (!existingManager && dimensionFilterManager.expr) { + this.dimensionFilterManagers.push(dimensionFilterManager); + } + return ret; + } + + public clear() { + this.measureFilterManagers = []; + this.dimensionFilterManagers = []; + this.temporaryFilter = undefined; + } + + public getOtherDimensionsFilter(name: string) { + const exprs = this.dimensionFilterManagers + .filter((dfm) => !!dfm.expr && dfm.name !== name) + .map((d) => d.expr as V1Expression); + return exprs.length === 0 ? undefined : createAndExpression(exprs); + } +} diff --git a/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts b/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts new file mode 100644 index 000000000000..a213531b1795 --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/measure-filter-manager.svelte.ts @@ -0,0 +1,90 @@ +import type { + MetricsViewSpecMeasure, + V1Expression, +} from "@rilldata/web-common/runtime-client"; +import { + mapExprToMeasureFilter, + mapMeasureFilterToExpr, + type MeasureFilterEntry, +} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry.ts"; +import { + MeasureFilterOperation, + MeasureFilterType, +} from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-options.ts"; +import { + createSubQueryExpression, + removeWrapperAndOrExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; + +export class MeasureFilterManager { + public expr: V1Expression | undefined = $state(undefined); + + public dimension: string; + public operation: MeasureFilterOperation; + public type: MeasureFilterType; + public value1: string; + public value2: string; + public pinned: boolean; + public required: boolean; + + public constructor( + public readonly name: string, + public readonly label: string, + public readonly measures: Map, + public readonly editing: boolean, // TODO: maybe separate editing, pinned & required? + initDimension: string = "", + initExpr: V1Expression | undefined = undefined, + ) { + const unwrappedHavingFilter = removeWrapperAndOrExpression( + initExpr?.subquery?.having, + ); + const mappedMeasureFilter = mapExprToMeasureFilter(unwrappedHavingFilter); + + this.dimension = $state(initDimension); + this.operation = $state( + mappedMeasureFilter?.operation ?? MeasureFilterOperation.LessThan, + ); + this.type = $state(mappedMeasureFilter?.type ?? MeasureFilterType.Value); + this.value1 = $state(mappedMeasureFilter?.value1 ?? ""); + this.value2 = $state(mappedMeasureFilter?.value2 ?? ""); + this.pinned = $state(false); + this.required = $state(false); + this.commit(); + } + + public apply(dimension: string, newFilter: MeasureFilterEntry) { + this.dimension = dimension; + this.operation = newFilter.operation; + this.type = newFilter.type; + this.value1 = newFilter.value1; + this.value2 = newFilter.value2; + this.commit(); + } + + public togglePinned() { + this.pinned = !this.pinned; + } + + public toggleRequired() { + this.required = !this.required; + } + + public clear() { + this.expr = undefined; + this.commit(); + } + + public commit() { + const measureFilterExpr = mapMeasureFilterToExpr({ + measure: this.name, + operation: this.operation, + type: this.type, + value1: this.value1, + value2: this.value2, + }); + const hasFilter = Boolean(this.dimension && measureFilterExpr); + this.expr = hasFilter + ? createSubQueryExpression(this.dimension, [this.name], measureFilterExpr) + : undefined; + } +} diff --git a/web-common/src/features/dashboards/filters/manager/queries.svelte.ts b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts new file mode 100644 index 000000000000..9c4762f4e68a --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/queries.svelte.ts @@ -0,0 +1,172 @@ +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; +import type { DimensionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/dimension-filter-manager.svelte.ts"; +import { createQuery } from "@tanstack/svelte-query"; +import { + getQueryServiceMetricsViewAggregationQueryOptions, + V1BuiltinMeasure, + type V1Expression, +} from "@rilldata/web-common/runtime-client"; +import { + createAndExpression, + createInExpression, + createLikeExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; +import { DimensionFilterMode } from "@rilldata/web-common/features/dashboards/filters/dimension-filters/constants.ts"; + +type DimensionSearchArgs = { + mode: DimensionFilterMode; + searchText: string; + values: string[]; + timeStart?: string; + timeEnd?: string; + timeDimension?: string; + enabled?: boolean; +}; + +export function getDimensionSearchQuery( + client: RuntimeClient, + manager: ExpressionFilterManager, + dimensionManager: DimensionFilterManager, + { + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + }: DimensionSearchArgs, +) { + const dimensionName = dimensionManager.name; + const mvName = [...dimensionManager.dimensions.keys()][0] ?? ""; + + const otherDimsFilter = $derived.by(() => { + manager.expr; // Force rederivation when expr changes. + return manager.getOtherDimensionsFilter(dimensionManager.name); + }); + const where = getFilterForSearchArgs(dimensionManager.name, { + mode, + searchText, + values, + additionalFilter: otherDimsFilter, + }); + + const optionsStore = $derived( + getQueryServiceMetricsViewAggregationQueryOptions( + client, + { + metricsView: mvName, + dimensions: [{ name: dimensionName }], + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + limit: "250", + offset: "0", + sort: [{ name: dimensionName }], + where, + }, + { + query: { + select: (resp) => + resp.data?.map((d) => d[dimensionName] as string) ?? [], + enabled, + }, + }, + ), + ); + return createQuery(optionsStore); +} + +export function getAllSearchResultsCount( + client: RuntimeClient, + manager: ExpressionFilterManager, + dimensionManager: DimensionFilterManager, + { + mode, + searchText, + values, + timeStart, + timeEnd, + timeDimension, + enabled, + }: DimensionSearchArgs, +) { + const dimensionName = dimensionManager.name; + const mvName = [...dimensionManager.dimensions.keys()][0] ?? ""; + const countMeasureName = dimensionName + "__distinct_count"; + + const otherDimsFilter = $derived.by(() => { + manager.expr; // Force rederivation when expr changes. + return manager.getOtherDimensionsFilter(dimensionManager.name); + }); + const where = getFilterForSearchArgs(dimensionManager.name, { + mode, + searchText, + values, + additionalFilter: otherDimsFilter, + }); + const optionsStore = $derived( + getQueryServiceMetricsViewAggregationQueryOptions( + client, + { + metricsView: mvName, + measures: [ + { + name: countMeasureName, + builtinMeasure: V1BuiltinMeasure.BUILTIN_MEASURE_COUNT_DISTINCT, + builtinMeasureArgs: [dimensionName], + }, + ], + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + where, + }, + { + query: { + enabled, + select: (resp) => { + if (!resp.data?.length) return 0; + return resp.data[0][countMeasureName] as number; + }, + }, + }, + ), + ); + return createQuery(optionsStore); +} + +/** + * Builds the filter for dimension search results or dimension search results count. + * Note the difference, this is for the search results from the search input. + * + * 1. For Select mode, while the final query is an `in` filter, the search results from the search input is a `like` filter. + * 2. For InList mode it is an `in` filter with all the selected values. + * 3. For Contains mode it is a `like` filter. + */ +function getFilterForSearchArgs( + dimensionName: string, + { + mode, + searchText, + values, + additionalFilter, + }: { + mode: DimensionFilterMode; + searchText: string; + values: string[]; + additionalFilter?: V1Expression; + }, +) { + let filter: V1Expression; + if (mode === DimensionFilterMode.InList) { + filter = createInExpression(dimensionName, values); + } else { + const addNull = searchText.length !== 0 && "null".includes(searchText); + filter = addNull + ? createInExpression(dimensionName, [null]) + : createLikeExpression(dimensionName, `%${searchText}%`); + } + + if (additionalFilter) { + return createAndExpression([filter, additionalFilter]); + } + return filter; +} diff --git a/web-common/src/features/dashboards/filters/manager/types.ts b/web-common/src/features/dashboards/filters/manager/types.ts new file mode 100644 index 000000000000..44398b69b135 --- /dev/null +++ b/web-common/src/features/dashboards/filters/manager/types.ts @@ -0,0 +1,6 @@ +import type { V1Expression } from "@rilldata/web-common/runtime-client"; + +export type RawParsedFilter = { + expr: V1Expression | undefined; + dimensionsWithInlistFilter: string[]; +}; diff --git a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte index d8b0af4a7f86..d73fbd66c99a 100644 --- a/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte +++ b/web-common/src/features/dashboards/leaderboard/LeaderboardDisplay.svelte @@ -35,7 +35,6 @@ measureTooltipFormatters, activeMeasureTooltipFormatter, }, - dimensionFilters: { isFilterExcludeMode }, dimensions: { visibleDimensions }, comparison: { isBeingCompared: isBeingComparedReadable }, sorting: { sortedAscending, sortType }, @@ -49,11 +48,11 @@ actions: { dimensions: { setPrimaryDimension }, sorting: { toggleSort }, - dimensionsFilter: { toggleDimensionValueSelection }, comparison: { toggleComparisonDimension }, }, exploreName, dashboardStore, + expressionFilterManager, } = StateManagers; const client = useRuntimeClient(); @@ -111,7 +110,9 @@ {dimensionColumnWidth} sortedAscending={$sortedAscending} sortType={$sortType} - filterExcludeMode={$isFilterExcludeMode(dimension.name)} + filterExcludeMode={expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimension.name, + )?.exclude ?? false} {comparisonTimeRange} {dimension} {parentElement} @@ -136,7 +137,12 @@ }} {setPrimaryDimension} {toggleSort} - {toggleDimensionValueSelection} + toggleDimensionValueSelection={(_1, value, _2, exclusive) => + expressionFilterManager.dimensionFilterAction( + dimension.name!, + (dimensionManager) => + dimensionManager.toggleValue(value, exclusive ?? false), + )} {toggleComparisonDimension} measureLabel={$measureLabel} onDimensionColumnResize={dimensionColumn.set} diff --git a/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts b/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts index e7a0c1a067f6..03201faec7bb 100644 --- a/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts +++ b/web-common/src/features/dashboards/state-managers/actions/dimension-filters.ts @@ -1,11 +1,9 @@ import { page } from "$app/stores"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; -import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils"; import { createInExpression, createLikeExpression, getValuesInExpression, - negateExpression, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; import { @@ -171,28 +169,6 @@ export function applyDimensionContainsMode( } } -export function toggleDimensionFilterMode( - { dashboard }: DashboardMutables, - dimensionName: string, -) { - const exclude = dashboard.dimensionFilterExcludeMode.get(dimensionName); - dashboard.dimensionFilterExcludeMode.set(dimensionName, !exclude); - - if (!dashboard.whereFilter?.cond?.exprs) { - return; - } - - const exprIdx = dashboard.whereFilter.cond.exprs.findIndex( - (e) => e.cond?.exprs?.[0].ident === dimensionName, - ); - if (exprIdx === -1) { - return; - } - dashboard.whereFilter.cond.exprs[exprIdx] = negateExpression( - dashboard.whereFilter.cond.exprs[exprIdx], - ); -} - export function removeDimensionFilter( { dashboard }: DashboardMutables, dimensionName: string, @@ -207,74 +183,6 @@ export function removeDimensionFilter( dashboard.whereFilter?.cond?.exprs?.splice(exprIdx, 1); } -export function selectItemsInFilter( - { dashboard }: DashboardMutables, - dimensionName: string, - values: (string | null)[], -) { - const isExclude = !!dashboard.dimensionFilterExcludeMode.get(dimensionName); - const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName); - if (exprIdx === undefined || exprIdx === -1) { - dashboard.whereFilter.cond?.exprs?.push( - createInExpression(dimensionName, values, isExclude), - ); - return; - } - - const expr = dashboard.whereFilter.cond?.exprs?.[exprIdx]; - if (!expr?.cond?.exprs) { - // should never happen since getWhereFilterExpressionIndex runs a find - return; - } - - // preserve old selections and add only new ones - const oldValues = getValuesInExpression(expr); - const newValues = values.filter((v) => !oldValues.includes(v)); - // newValuesSelected = newValues.length; // TODO - expr.cond.exprs.push(...newValues.map((v): V1Expression => ({ val: v }))); -} - -export function deselectItemsInFilter( - { dashboard }: DashboardMutables, - dimensionName: string, - values: (string | null)[], -) { - const exprIdx = getWhereFilterExpressionIndex({ dashboard })(dimensionName); - if (exprIdx === undefined || exprIdx === -1) { - return; - } - - const expr = dashboard.whereFilter.cond?.exprs?.[exprIdx]; - if (!expr?.cond?.exprs) { - // should never happen since getWhereFilterExpressionIndex runs a find - return; - } - - // remove only deselected values - const oldValues = getValuesInExpression(expr); - const newValues = oldValues.filter((v) => !values.includes(v)); - - if (newValues.length) { - expr.cond.exprs.splice( - 1, - expr.cond.exprs.length - 1, - ...newValues.map((v): V1Expression => ({ val: v })), - ); - } else { - dashboard.whereFilter.cond?.exprs?.splice(exprIdx, 1); - } -} - -export function setFilters( - { dashboard }: DashboardMutables, - filter: V1Expression, -) { - const { dimensionFilters, dimensionThresholdFilters } = - splitWhereFilter(filter); - dashboard.whereFilter = dimensionFilters; - dashboard.dimensionThresholdFilters = dimensionThresholdFilters; -} - export function toggleDimensionFilterValue( expr: V1Expression, dimensionValue: string | null, @@ -303,22 +211,4 @@ export function toggleDimensionFilterValue( return inIdx; } -export const dimensionFilterActions = { - /** - * Toggles whether the given dimension value is selected in the - * dimension filter for the given dimension. - * - * Note that this is different than the include/exclude mode for - * dimension filters. This is a toggle for a specific value, whereas - * the include/exclude mode is a toggle for the entire dimension. - */ - toggleDimensionValueSelection, - toggleMultipleDimensionValueSelections, - applyDimensionInListMode, - applyDimensionContainsMode, - toggleDimensionFilterMode, - removeDimensionFilter, - selectItemsInFilter, - deselectItemsInFilter, - setFilters, -}; +export const dimensionFilterActions = {}; diff --git a/web-common/src/features/dashboards/state-managers/actions/filters.ts b/web-common/src/features/dashboards/state-managers/actions/filters.ts index 32749c48449f..fb254ec20efa 100644 --- a/web-common/src/features/dashboards/state-managers/actions/filters.ts +++ b/web-common/src/features/dashboards/state-managers/actions/filters.ts @@ -1,5 +1,6 @@ import type { DashboardMutables } from "@rilldata/web-common/features/dashboards/state-managers/actions/types"; import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import type { V1Expression } from "@rilldata/web-common/runtime-client"; export function clearAllFilters({ dashboard }: DashboardMutables) { const hasFilters = @@ -23,6 +24,15 @@ export function setTemporaryFilterName( dashboard.temporaryFilterName = name; } +export function setFilter( + { dashboard }: DashboardMutables, + expr: V1Expression, + inList: string[], +) { + dashboard.whereFilter = expr; + dashboard.dimensionThresholdFilters = inList; +} + export const filterActions = { /** * Clears all filters and resets related fields @@ -30,4 +40,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..2d3569e039d4 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 { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import type { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; export const DASHBOARD_STATE_SYNC_KEY = Symbol("state-sync"); @@ -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( @@ -185,6 +188,10 @@ export class DashboardStateSync { ); } + log("INIT", redirectUrl); + this.expressionFilterManager.setExprParam( + redirectUrl.searchParams.get(ExploreStateURLParams.Filters) ?? "", + ); // If the current url same as the new url then there is no need to do anything if (redirectUrl.search === pageState.url.search) { this.initialized = true; @@ -294,6 +301,10 @@ export class DashboardStateSync { this.updating = false; } + log("URL", redirectUrl); + this.expressionFilterManager.setExprParam( + redirectUrl.searchParams.get(ExploreStateURLParams.Filters) ?? "", + ); // If the url doesn't need to be changed further then we can skip the goto if (redirectUrl.search === pageState.url.search) { return; @@ -349,6 +360,10 @@ export class DashboardStateSync { ); } + log("GOTO", newUrl); + this.expressionFilterManager.setExprParam( + newUrl.searchParams.get(ExploreStateURLParams.Filters) ?? "", + ); // If the state didnt result in a new url then skip goto. // This avoids adding redundant urls to the history. if (newUrl.search === pageState.url.search) { @@ -362,3 +377,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/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..487d75c88924 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -33,6 +33,7 @@ import { contextColWidthDefaults, type ContextColWidths, } from "../leaderboard-context-column"; +import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; export type StateManagers = { runtimeClient: RuntimeClient; @@ -65,6 +66,7 @@ export type StateManagers = { */ contextColumnWidths: Writable; defaultExploreState: Readable; + expressionFilterManager: ExpressionFilterManager; }; export const DEFAULT_STORE_KEY = Symbol("state-managers"); @@ -191,5 +193,6 @@ export function createStateManagers({ }), contextColumnWidths, defaultExploreState, + expressionFilterManager: new ExpressionFilterManager(), }; } diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte index 9b47263de2d1..676c72594dbc 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte @@ -30,15 +30,29 @@ import type { TDDComparison } from "./types"; import { V1TimeGrainToDateTimeUnit } from "@rilldata/web-common/lib/time/new-grains"; - export let exploreName: string; - export let dimensionName: string; - export let isFetching = false; - export let comparing: TDDComparison | undefined; - export let areAllTableRowsSelected = false; - export let isRowsEmpty = false; - export let expandedMeasureName: string; - export let onToggleSearchItems: () => void; - export let hideStartPivotButton = false; + interface Props { + exploreName: string; + dimensionName: string; + isFetching?: boolean; + comparing: TDDComparison | undefined; + areAllTableRowsSelected?: boolean; + isRowsEmpty?: boolean; + expandedMeasureName: string; + onToggleSearchItems: () => void; + hideStartPivotButton?: boolean; + } + + let { + exploreName, + dimensionName, + isFetching = false, + comparing, + areAllTableRowsSelected = false, + isRowsEmpty = false, + expandedMeasureName, + onToggleSearchItems, + hideStartPivotButton = false, + }: Props = $props(); const { adminServer, exports } = featureFlags; const stateManagers = getStateManagers(); @@ -48,30 +62,34 @@ measures: { measureLabel, allMeasures }, dimensions: { getDimensionDisplayName }, }, - actions: { - dimensionsFilter: { toggleDimensionFilterMode }, - }, dashboardStore, validSpecStore, + expressionFilterManager, } = stateManagers; - $: selectableMeasures = $allMeasures - .filter((m) => m.name !== undefined || m.displayName !== undefined) - .map((m) => - // Note: undefined values are filtered out above, so the - // empty string fallback is unreachable. - ({ - name: m.name || "", - label: m.displayName || "", - }), - ); + const selectableMeasures = $derived( + $allMeasures + .filter((m) => m.name !== undefined || m.displayName !== undefined) + .map((m) => + // Note: undefined values are filtered out above, so the + // empty string fallback is unreachable. + ({ + name: m.name || "", + label: m.displayName || "", + }), + ), + ); - $: selectedMeasureLabel = + const selectedMeasureLabel = $derived( $allMeasures.find((m) => m.name === expandedMeasureName)?.displayName || - expandedMeasureName; + expandedMeasureName, + ); - $: excludeMode = - $dashboardStore?.dimensionFilterExcludeMode.get(dimensionName) ?? false; + const excludeMode = $derived( + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimensionName, + )?.exclude ?? false, + ); function closeSearchBar() { dimensionSearchText.set(""); @@ -85,14 +103,17 @@ } function toggleFilterMode() { - toggleDimensionFilterMode(dimensionName); + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.toggleExclude(), + ); } function switchMeasure(measureName: string) { metricsExplorerStore.setExpandedMeasureName(exploreName, measureName); } - let showReplacePivotModal = false; + let showReplacePivotModal = $state(false); function startPivotForTDD() { const pivot = $dashboardStore?.pivot; @@ -139,17 +160,21 @@ const timeControlsStore = useTimeControlStore(stateManagers); - $: ({ minTimeGrain, timeStart, timeEnd, selectedTimeRange } = - $timeControlsStore); + const minTimeGrain = $derived($timeControlsStore.minTimeGrain); + const timeStart = $derived($timeControlsStore.timeStart); + const timeEnd = $derived($timeControlsStore.timeEnd); + const selectedTimeRange = $derived($timeControlsStore.selectedTimeRange); - $: activeTimeGrain = selectedTimeRange?.interval; + const activeTimeGrain = $derived(selectedTimeRange?.interval); - $: baseTimeRange = selectedTimeRange?.start && - selectedTimeRange?.end && { - name: selectedTimeRange?.name, - start: selectedTimeRange.start, - end: selectedTimeRange.end, - }; + const baseTimeRange = $derived( + selectedTimeRange?.start && + selectedTimeRange?.end && { + name: selectedTimeRange?.name, + start: selectedTimeRange.start, + end: selectedTimeRange.end, + }, + ); function onTimeGrainSelect(timeGrain: V1TimeGrain) { if (baseTimeRange) { diff --git a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte index 81d0b160b726..4edf67613f33 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TimeDimensionDisplay.svelte @@ -23,84 +23,105 @@ import type { TDDComparison, TableData } from "./types"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; - export let exploreName: string; - export let expandedMeasureName: string; - export let hideStartPivotButton = false; + interface Props { + exploreName: string; + expandedMeasureName: string; + hideStartPivotButton?: boolean; + } + + let { + exploreName, + expandedMeasureName, + hideStartPivotButton = false, + }: Props = $props(); - $: isEmbedded = EmbedStore.isEmbedded(); + const isEmbedded = $derived(EmbedStore.isEmbedded()); const stateManagers = getStateManagers(); const { dashboardStore, selectors: { dimensions: { allDimensions }, - dimensionFilters: { unselectedDimensionValues }, measures: { allMeasures }, }, actions: { - dimensionsFilter: { - toggleDimensionValueSelection, - selectItemsInFilter, - deselectItemsInFilter, - }, sorting: { toggleSort }, }, + expressionFilterManager, } = getStateManagers(); const timeDimensionDataStore = useTimeDimensionDataStore(stateManagers); const timeControlStore = useTimeControlStore(stateManagers); - $: dimensionName = $dashboardStore?.selectedComparisonDimension ?? ""; - $: comparing = $timeDimensionDataStore?.comparing; + const dimensionName = $derived( + $dashboardStore?.selectedComparisonDimension ?? "", + ); + const comparing = $derived($timeDimensionDataStore?.comparing); - $: pinIndex = $dashboardStore?.tdd.pinIndex; + const pinIndex = $derived($dashboardStore?.tdd.pinIndex); - $: timeGrain = $timeControlStore.selectedTimeRange?.interval; + const timeGrain = $derived($timeControlStore.selectedTimeRange?.interval); - $: measure = $allMeasures.find((m) => m.name === expandedMeasureName); + const measure = $derived( + $allMeasures.find((m) => m.name === expandedMeasureName), + ); - $: measureLabel = measure?.displayName ?? ""; + const measureLabel = $derived(measure?.displayName ?? ""); - let dimensionLabel = ""; - $: if (comparing === "dimension") { - dimensionLabel = - $allDimensions.find((d) => d.name === dimensionName)?.displayName ?? ""; - } else if (comparing === "time") { - dimensionLabel = m.dashboard_tdd_time(); - } else if (comparing === "none") { - dimensionLabel = m.dashboard_tdd_no_comparison(); - } + const dimensionLabel = $derived.by(() => { + if (comparing === "dimension") { + return ( + $allDimensions.find((d) => d.name === dimensionName)?.displayName ?? "" + ); + } else if (comparing === "time") { + return m.dashboard_tdd_time(); + } else if (comparing === "none") { + return m.dashboard_tdd_no_comparison(); + } + return ""; + }); // Create a copy of the data to avoid flashing of table in transient states - let timeDimensionDataCopy: TableData; - let comparisonCopy: TDDComparison | undefined; - $: if ( - $timeDimensionDataStore?.data && - $timeDimensionDataStore?.data?.columnHeaderData - ) { - comparisonCopy = comparing; - timeDimensionDataCopy = $timeDimensionDataStore.data; - } - $: formattedData = timeDimensionDataCopy; - $: excludeMode = - $dashboardStore?.dimensionFilterExcludeMode.get(dimensionName) ?? false; + let timeDimensionDataCopy = $state(); + let comparisonCopy = $state(); + $effect(() => { + if ( + $timeDimensionDataStore?.data && + $timeDimensionDataStore?.data?.columnHeaderData + ) { + comparisonCopy = comparing; + timeDimensionDataCopy = $timeDimensionDataStore.data; + } + }); + const formattedData = $derived(timeDimensionDataCopy); + const excludeMode = $derived( + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === dimensionName, + )?.exclude ?? false, + ); - $: rowHeaderLabels = - formattedData?.rowHeaderData?.slice(1)?.map((row) => row[0]?.value) ?? []; + const rowHeaderLabels = $derived( + formattedData?.rowHeaderData?.slice(1)?.map((row) => row[0]?.value) ?? [], + ); - $: areAllTableRowsSelected = rowHeaderLabels?.every( - (val) => val !== undefined && formattedData?.selectedValues?.includes(val), + const areAllTableRowsSelected = $derived( + rowHeaderLabels?.every( + (val) => + val !== undefined && formattedData?.selectedValues?.includes(val), + ), ); - $: columnHeaders = formattedData?.columnHeaderData?.flat(); + const columnHeaders = $derived(formattedData?.columnHeaderData?.flat()); - $: highlightedColStart = $hoverIndex?.start; - $: highlightedColEnd = $hoverIndex?.end; + const highlightedColStart = $derived($hoverIndex?.start); + const highlightedColEnd = $derived($hoverIndex?.end); // Create a time formatter for the column headers - $: timeFormatter = timeFormat( - timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", - ) as (d: Date) => string; + const timeFormatter = $derived( + timeFormat( + timeGrain ? TIME_GRAIN[timeGrain].d3format : "%H:%M", + ) as (d: Date) => string, + ); function highlightCell(x: number | undefined, y: number | undefined) { if (x === undefined || y === undefined) { @@ -127,8 +148,11 @@ const debounceHighlightCell = debounce(highlightCell, 50); - function toggleFilter(label: string | null) { - toggleDimensionValueSelection(dimensionName, label); + function toggleFilter(label: string) { + expressionFilterManager.dimensionFilterAction( + dimensionName, + (dimensionManager) => dimensionManager.toggleValue(label, false), + ); } function toggleAllSearchItems() { @@ -139,9 +163,10 @@ if (headerHasUndefined) return; if (areAllTableRowsSelected) { - deselectItemsInFilter( + expressionFilterManager.dimensionFilterAction( dimensionName, - rowHeaderLabels as (string | null)[], + (dimensionManager) => + dimensionManager.removeSelectedValues(rowHeaderLabels as string[]), ); eventBus.emit("notification", { @@ -151,11 +176,11 @@ }); return; } else { - const newValuesSelected = $unselectedDimensionValues( + const newValuesSelected = expressionFilterManager.dimensionFilterAction( dimensionName, - rowHeaderLabels, + (dimensionManager) => + dimensionManager.appendSelectedValues(rowHeaderLabels as string[]), ); - selectItemsInFilter(dimensionName, rowHeaderLabels as (string | null)[]); eventBus.emit("notification", { message: m.dashboard_added_items_filter({ count: newValuesSelected.length.toString(), @@ -166,23 +191,24 @@ function togglePin() { let newPinIndex = -1; + const selectedCount = formattedData?.selectedValues?.length ?? 0; // Pin if some selected items are not pinned yet - if (pinIndex > -1 && pinIndex < formattedData?.selectedValues?.length - 1) { - newPinIndex = formattedData?.selectedValues?.length - 1; + if (pinIndex > -1 && pinIndex < selectedCount - 1) { + newPinIndex = selectedCount - 1; } // Pin if no items are pinned yet else if (pinIndex === -1) { - newPinIndex = formattedData?.selectedValues?.length - 1; + newPinIndex = selectedCount - 1; } metricsExplorerStore.setPinIndex(exploreName, newPinIndex); } - function handleKeyDown(e) { + function handleKeyDown(e: KeyboardEvent) { if (comparisonCopy !== "dimension") return; // Select all items on Meta+A if ((e.ctrlKey || e.metaKey) && e.key === "a") { - if (e.target.tagName === "INPUT") return; + if ((e.target as HTMLElement)?.tagName === "INPUT") return; e.preventDefault(); if (areAllTableRowsSelected) return; toggleAllSearchItems(); diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index 1a4832521b3b..324782dcd285 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -66,13 +66,13 @@ dashboardStore, selectors: { measures: { allMeasures, visibleMeasures, getMeasureByName }, - dimensionFilters: { includedDimensionValues }, charts: { canPanLeft, canPanRight, getNewPanRange }, tags: { measureTagIndex }, }, actions: { measures: { setMeasureVisibility }, }, + expressionFilterManager, } = StateManagers; const timeControlsStore = useTimeControlStore(StateManagers); @@ -148,9 +148,10 @@ const [start, end] = a <= b ? [a, b] : [b, a]; return Interval.fromDateTimes(start, end) as Interval; })(); - $: includedValuesForDimension = $includedDimensionValues( - comparisonDimension as string, - ); + $: includedValuesForDimension = + expressionFilterManager.dimensionFilterManagers.find( + (dfm) => dfm.name === comparisonDimension, + )?.selectedValues ?? []; $: chartDimensionValues = includedValuesForDimension.slice( 0, showTimeDimensionDetail ? 11 : 7, diff --git a/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte b/web-common/src/features/scheduled-reports/BaseScheduledReportForm.svelte index 8780279c5271..369b82748441 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 "@rilldata/web-common/features/dashboards/filters/manager/expression-filter-manager.svelte.ts"; export let formId: string; export let data: Readable; @@ -31,7 +31,7 @@ export let submit: () => void; export let enhance; export let exploreName: string; - export let filters: Filters; + export let filters: ExpressionFilterManager; export let timeControls: TimeControls; const RUN_AS_OPTIONS = [ diff --git a/web-common/src/features/scheduled-reports/FiltersForm.svelte b/web-common/src/features/scheduled-reports/FiltersForm.svelte index 3aa46b4e2261..65ecb93e8043 100644 --- a/web-common/src/features/scheduled-reports/FiltersForm.svelte +++ b/web-common/src/features/scheduled-reports/FiltersForm.svelte @@ -1,18 +1,9 @@