From f93a16d823f1da5eefceb1bd014001f47895e879 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 26 Aug 2026 11:48:06 +0530 Subject: [PATCH 01/15] refac: unified time filter components --- .../DashboardConfigProvider.svelte.ts | 99 ++++ .../providers/YAMLConfigProvider.svelte.ts | 97 ++++ .../ComparisonTimeRangeManager.svelte.ts | 136 +++++ .../ComparisonTimeRangePicker.svelte | 212 ++++++++ .../time-controls/TimeFilterManager.svelte.ts | 27 + .../time-controls/TimeFilters.svelte | 115 ++++ .../time-controls/TimeRangeManager.svelte.ts | 245 +++++++++ .../time-controls/TimeRangePicker.svelte | 498 ++++++++++++++++++ .../time-controls/time-filters-config.ts | 14 + .../time-controls/time-range-utils.ts | 171 +----- .../providers/MetricsViewsProvider.svelte.ts | 279 ++++++++++ 11 files changed, 1749 insertions(+), 144 deletions(-) create mode 100644 web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts create mode 100644 web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts create mode 100644 web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts create mode 100644 web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte create mode 100644 web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts create mode 100644 web-common/src/features/dashboards/time-controls/TimeFilters.svelte create mode 100644 web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts create mode 100644 web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte create mode 100644 web-common/src/features/dashboards/time-controls/time-filters-config.ts create mode 100644 web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts diff --git a/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts new file mode 100644 index 000000000000..e612ce3f7987 --- /dev/null +++ b/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts @@ -0,0 +1,99 @@ +import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; +import { + createQueryServiceResolveCanvas, + createRuntimeServiceGetExplore, +} from "@rilldata/web-common/runtime-client"; +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; + +/** + * Metrics view name and other yaml config provider based on dashboard type. + */ +export class DashboardConfigProvider { + public readonly metricsViewsProvider: MetricsViewsProvider; + public readonly yamlConfigProvider: YAMLConfigProvider; + + public cleanup: (() => void) | undefined = undefined; + + public constructor(runtimeClient: RuntimeClient) { + this.metricsViewsProvider = new MetricsViewsProvider(runtimeClient, []); + this.yamlConfigProvider = new YAMLConfigProvider(); + } +} + +export class ExploreDashboardConfigProvider extends DashboardConfigProvider { + public constructor(runtimeClient: RuntimeClient, exploreName: string) { + super(runtimeClient); + + const getExploreQuery = createRuntimeServiceGetExplore(runtimeClient, { + name: exploreName, + }); + const getExploreUnsub = getExploreQuery.subscribe((getExploreResp) => { + const metricsViewSpec = + getExploreResp.data?.metricsView?.metricsView?.state?.validSpec ?? {}; + const exploreSpec = + getExploreResp.data?.explore?.explore?.state?.validSpec ?? {}; + + this.metricsViewsProvider.setMetricsViewNames( + exploreSpec.metricsView ? [exploreSpec.metricsView] : [], + ); + + this.yamlConfigProvider.update({ + restrictedDimensions: exploreSpec.dimensions, + primaryTimeDimension: metricsViewSpec.timeDimension, + restrictedMeasures: exploreSpec.measures, + + defaultTimeRange: exploreSpec.defaultPreset?.timeRange, + timeRanges: exploreSpec.timeRanges, + timeZones: exploreSpec.timeZones, + }); + }); + + this.cleanup = () => { + getExploreUnsub(); + this.metricsViewsProvider.cleanup(); + this.yamlConfigProvider.cleanup?.(); + }; + } +} + +export class CanvasDashboardConfigProvider extends DashboardConfigProvider { + public constructor(runtimeClient: RuntimeClient, canvasName: string) { + super(runtimeClient); + + const resolveCanvasQuery = createQueryServiceResolveCanvas(runtimeClient, { + canvas: canvasName, + }); + const resolveCanvasUnsub = resolveCanvasQuery.subscribe( + (resolveCanvasResp) => { + const canvasSpec = + resolveCanvasResp.data?.canvas?.canvas?.state?.validSpec ?? {}; + + this.metricsViewsProvider.setMetricsViewNames( + Object.keys(resolveCanvasResp.data?.referencedMetricsViews ?? {}), + ); + + const defaultFilters = Object.fromEntries( + Object.entries(canvasSpec.defaultPreset?.filterExpr ?? {}).map( + ([mv, sqlFilter]) => [mv, sqlFilter.expression], + ), + ); + this.yamlConfigProvider.update({ + defaultFilters, + pinnedFilters: canvasSpec.pinnedFilters, + requiredFilters: canvasSpec.requiredFilters, + + defaultTimeRange: canvasSpec.defaultPreset?.timeRange, + timeRanges: canvasSpec.timeRanges, + timeZones: canvasSpec.timeZones, + }); + }, + ); + + this.cleanup = () => { + resolveCanvasUnsub(); + this.metricsViewsProvider.cleanup(); + this.yamlConfigProvider.cleanup?.(); + }; + } +} 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..617aea4262d8 --- /dev/null +++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts @@ -0,0 +1,97 @@ +import { + type V1ExploreTimeRange, + type V1Expression, +} from "@rilldata/web-common/runtime-client"; +import { DEFAULT_TIMEZONES } from "@rilldata/web-common/lib/time/config.ts"; +import type { DateTime } from "luxon"; + +/** + * 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 restrictedDimensions = $state(undefined); + public primaryTimeDimension = $state(undefined); + public restrictedMeasures = $state(undefined); + + public defaultTimeRange = $state(undefined); + public timeRanges = $state([]); + public timeZones = $state(DEFAULT_TIMEZONES); + + public editable = $state(false); + + public cleanup: (() => void) | undefined = undefined; + + public update({ + defaultFilters, + pinnedFilters, + requiredFilters, + + restrictedDimensions, + primaryTimeDimension, + restrictedMeasures, + + defaultTimeRange, + timeRanges, + timeZones, + }: { + defaultFilters?: YAMLConfigProvider["defaultFilters"]; + pinnedFilters?: string[]; + requiredFilters?: string[]; + + restrictedDimensions?: YAMLConfigProvider["restrictedDimensions"]; + primaryTimeDimension?: YAMLConfigProvider["primaryTimeDimension"]; + restrictedMeasures?: YAMLConfigProvider["restrictedMeasures"]; + + defaultTimeRange?: YAMLConfigProvider["defaultTimeRange"]; + timeRanges?: YAMLConfigProvider["timeRanges"]; + timeZones?: YAMLConfigProvider["timeZones"]; + }) { + this.defaultFilters = defaultFilters ?? {}; + + const pinnedFiltersRec = Object.fromEntries( + pinnedFilters?.map((filter) => [filter, true]) ?? [], + ); + this.pinnedFilters = { ...pinnedFiltersRec }; + this.specPinnedFilters = { ...pinnedFiltersRec }; + + const requiredFiltersRec = Object.fromEntries( + requiredFilters?.map((filter) => [filter, true]) ?? [], + ); + this.requiredFilters = { ...requiredFiltersRec }; + this.specRequiredFilters = { ...requiredFiltersRec }; + + this.restrictedDimensions = restrictedDimensions; + this.primaryTimeDimension = primaryTimeDimension; + this.restrictedMeasures = restrictedMeasures; + + this.defaultTimeRange = defaultTimeRange; + this.timeRanges = timeRanges ?? []; + this.timeZones = timeZones ?? []; + } + + public setEditable(newEditable: boolean) { + this.editable = newEditable; + } + + public togglePinnedFilter(filter: string) { + if (!this.pinnedFilters[filter]) { + this.pinnedFilters[filter] = true; + } else { + delete this.pinnedFilters[filter]; + } + } + + public toggleRequiredFilter(filter: string) { + if (!this.requiredFilters[filter]) { + this.requiredFilters[filter] = true; + } else { + delete this.requiredFilters[filter]; + } + } +} diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts new file mode 100644 index 000000000000..fa0b3c7c19ab --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts @@ -0,0 +1,136 @@ +import { TimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeRangeManager.svelte.ts"; +import { + RillIsoInterval, + RillTime, +} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime.ts"; +import { parseRillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser.ts"; +import { type Interval } from "luxon"; +import { + getAvailableComparisonsForTimeRange, + getComparisonInterval, +} from "@rilldata/web-common/lib/time/comparisons"; +import { TimeComparisonOption } from "@rilldata/web-common/lib/time/types.ts"; +import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; + +type ComparisonTimeRangeOption = { + name: TimeComparisonOption; + key: number; + interval: Interval; +}; + +export class ComparisonTimeRangeManager { + public comparisonTimeRange = $state(undefined); + public showComparison = $state(false); + + public comparisonTimeRangeOptions: ComparisonTimeRangeOption[]; + + public interval: Interval | undefined; + public parsedTime: RillTime | undefined; + + public constructor( + private readonly yamlConfigProvider: YAMLConfigProvider, + private readonly timeRangeManager: TimeRangeManager, + private readonly allowCustomTimeRange: boolean, + ) { + this.comparisonTimeRangeOptions = $derived( + this.getComparisonTimeRangeOptions(), + ); + + this.parsedTime = $derived.by(() => { + if (!this.comparisonTimeRange) return undefined; + try { + return parseRillTime(this.comparisonTimeRange); + } catch { + return undefined; + } + }); + } + + public onSelectComparisonRange = (range: string) => { + // TODO: reassign when primary time range changes. + + this.comparisonTimeRange = range; + if (!this.showComparison) { + this.interval = undefined; + return; + } + + try { + const parsed = parseRillTime(range); + if (parsed.interval instanceof RillIsoInterval) { + // TODO + } else { + this.interval = getComparisonInterval( + this.timeRangeManager.interval, + range, + this.timeRangeManager.timeZone, + ); + } + } catch { + return undefined; + } + }; + + public onToggleShowComparison = () => { + this.showComparison = !this.showComparison; + }; + + private getComparisonTimeRangeOptions() { + if ( + !this.timeRangeManager.minDate || + !this.timeRangeManager.maxDate || + !this.timeRangeManager.timeRange || + !this.timeRangeManager.interval?.isValid || + !this.timeRangeManager.interval.start || + !this.timeRangeManager.interval.end + ) + return []; + + let allOptions: TimeComparisonOption[]; + + const timeRange = this.yamlConfigProvider.timeRanges?.find( + (tr) => tr.range === this.timeRangeManager.timeRange, + ); + if (timeRange?.comparisonTimeRanges?.length) { + allOptions = + timeRange.comparisonTimeRanges?.map( + (co) => co.offset as TimeComparisonOption, + ) ?? []; + if (this.allowCustomTimeRange) + allOptions.push(TimeComparisonOption.CUSTOM); + } else { + allOptions = [...Object.values(TimeComparisonOption)]; + if (!this.allowCustomTimeRange) { + allOptions = allOptions.filter( + (o) => o !== TimeComparisonOption.CUSTOM, + ); + } + } + + const timeComparisonOptions = getAvailableComparisonsForTimeRange( + this.timeRangeManager.minDate.toJSDate(), + this.timeRangeManager.maxDate.toJSDate(), + this.timeRangeManager.interval.start.toJSDate(), + this.timeRangeManager.interval.end.toJSDate(), + allOptions, + this.timeRangeManager.timeZone, + ); + + return timeComparisonOptions + .map((co, i) => { + const comparisonTimeRange = getComparisonInterval( + this.timeRangeManager.interval, + co, + this.timeRangeManager.timeZone, + ); + + if (!comparisonTimeRange) return undefined; + return { + name: co, + key: i, + interval: comparisonTimeRange, + }; + }) + .filter(Boolean) as ComparisonTimeRangeOption[]; + } +} diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte new file mode 100644 index 000000000000..a58ed2000bbb --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte @@ -0,0 +1,212 @@ + + +
+ + {#if timeGrain && interval} + { + showSelector = !!( + comparisonTimeRange === TimeComparisonOption.CUSTOM && showComparison + ); + }} + > + + {#snippet child({ props })} + + {/snippet} + + + +
+
+ {#each comparisonTimeRangeOptions as option (option.name)} + {@const preset = TIME_COMPARISON[option.name]} + {@const selected = selectedLabel === option.name} + { + onSelectComparisonRange(option.name); + open = false; + }} + > + + {preset?.label || option.name} + + + {#if option.name === TimeComparisonOption.CONTIGUOUS && comparisonTimeRangeOptions.length > 2} + + {/if} + {/each} + {#if allowCustomTimeRange} + {#if comparisonTimeRangeOptions.length} + + {/if} + + { + showSelector = !showSelector; + }} + > + + {m.time_custom()} + + + {/if} +
+ {#if showSelector} +
+ {#if !interval || interval?.isValid} + (open = false)} + /> + {/if} +
+ {/if} +
+
+
+ + + {/if} +
+ + diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts new file mode 100644 index 000000000000..ee3c01ffb28c --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts @@ -0,0 +1,27 @@ +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { TimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeRangeManager.svelte.ts"; +import { ComparisonTimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts"; +import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; + +export class TimeFilterManager { + public timeRangeManager: TimeRangeManager; + public comparisonTimeRangeManager: ComparisonTimeRangeManager; + + public constructor( + runtimeClient: RuntimeClient, + metricsViewsProvider: MetricsViewsProvider, + yamlConfigProvider: YAMLConfigProvider, + allowCustomTimeRange: boolean, + ) { + this.timeRangeManager = new TimeRangeManager( + runtimeClient, + metricsViewsProvider, + ); + this.comparisonTimeRangeManager = new ComparisonTimeRangeManager( + yamlConfigProvider, + this.timeRangeManager, + allowCustomTimeRange, + ); + } +} diff --git a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte new file mode 100644 index 000000000000..e930546e1485 --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte @@ -0,0 +1,115 @@ + + +
+ + + + + + + + + +
+ {#if !hidePan} + + + {/if} + + + + +
+
+ + diff --git a/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts new file mode 100644 index 000000000000..285e4b625f7d --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts @@ -0,0 +1,245 @@ +import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { type Interval, DateTime } from "luxon"; +import { + RillIsoInterval, + RillPeriodToGrainInterval, + RillTime, + RillTimeLabel, +} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime.ts"; +import { V1TimeGrain } from "@rilldata/web-common/runtime-client"; +import { + overrideRillTimeRef, + parseRillTime, +} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser.ts"; +import { getTruncationGrain } from "@rilldata/web-common/lib/time/rill-time-grains.ts"; +import { + allowedGrainsForInterval, + getGrainOrder, + V1TimeGrainToOrder, +} from "@rilldata/web-common/lib/time/new-grains.ts"; +import { + constructAsOfString, + constructNewString, + deriveInterval, +} from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; +import { invalidationForMetricsViewData } from "@rilldata/web-common/runtime-client/invalidation.ts"; +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; + +export class TimeRangeManager { + public timeRange = $state(undefined); + public timeGrain = $state(undefined); + public timeZone = $state("UTC"); + public timeDimension = $state(undefined); + + public minDate: DateTime | undefined; + public maxDate: DateTime | undefined; + + public interval: Interval | undefined; + public parsedTime: RillTime | undefined; + public truncationGrain: V1TimeGrain | undefined; + public ref: RillTimeLabel | string | undefined; + public snapToEnd: boolean; + + public constructor( + private readonly runtimeClient: RuntimeClient, + private readonly metricsViewsProvider: MetricsViewsProvider, + ) { + this.minDate = $derived.by(() => { + const minDate = this.metricsViewsProvider.timeRangeSummary?.min + ? DateTime.fromISO(this.metricsViewsProvider.timeRangeSummary.min) + : undefined; + if (!minDate?.isValid) return undefined; + return minDate; + }); + this.maxDate = $derived.by(() => { + const maxDate = this.metricsViewsProvider.timeRangeSummary?.max + ? DateTime.fromISO(this.metricsViewsProvider.timeRangeSummary.max) + : undefined; + if (!maxDate?.isValid) return undefined; + return maxDate; + }); + + this.parsedTime = $derived.by(() => { + if (!this.timeRange) return undefined; + try { + return parseRillTime(this.timeRange); + } catch { + return undefined; + } + }); + this.truncationGrain = $derived(getTruncationGrain(this.parsedTime)); + this.ref = $derived( + this.parsedTime?.isOldFormat + ? RillTimeLabel.Latest + : this.parsedTime?.asOfLabel?.label, + ); + this.snapToEnd = $derived( + this.parsedTime?.isOldFormat + ? true + : !!this.parsedTime?.asOfLabel?.offset, + ); + } + + public onSelectRange = (range: string, ignoreSnap?: boolean) => { + try { + const parsed = parseRillTime(range); + + const isPeriodToDate = + parsed.interval instanceof RillPeriodToGrainInterval; + + const rangeGrainOrder = + getGrainOrder(parsed.rangeGrain) - (isPeriodToDate ? 1 : 0); + + const asOfGrainOrder = getGrainOrder(this.truncationGrain); + + const shouldAppendAsOfString = + !parsed.asOfLabel && !(parsed.interval instanceof RillIsoInterval); + + if (asOfGrainOrder > rangeGrainOrder && parsed.rangeGrain) { + this.truncationGrain = parsed.rangeGrain; + } + + if (shouldAppendAsOfString) { + const hasAsOfClause = !!this.parsedTime?.asOfLabel; + + const isTruncationGrainAllowed = + getGrainOrder(this.truncationGrain) >= + this.metricsViewsProvider.smallestGrainOrder; + const newAsOfString = constructAsOfString( + this.ref ?? RillTimeLabel.Latest, + ignoreSnap + ? undefined + : this.truncationGrain + ? isTruncationGrainAllowed + ? this.truncationGrain + : parsed.rangeGrain + : (this.metricsViewsProvider.smallestTimeGrain ?? + V1TimeGrain.TIME_GRAIN_MINUTE), + hasAsOfClause || this.snapToEnd ? this.snapToEnd : true, + ); + + overrideRillTimeRef(parsed, newAsOfString); + } + + return this.applyTimeRange(parsed.toString()); + } catch { + // This function is called in a controlled manner and should not throw + } + }; + + public onSelectGrain = (grain: V1TimeGrain | undefined) => { + if (!this.timeRange) return; + + const newString = constructNewString({ + currentString: this.timeRange, + truncationGrain: grain === this.truncationGrain ? undefined : grain, + snapToEnd: grain === this.truncationGrain ? false : this.snapToEnd, + ref: this.ref, + }); + + return this.applyTimeRange(newString); + }; + + public onSelectZone = (tz: string) => { + this.timeZone = tz; + if (!this.timeRange || !this.parsedTime) return; + + if (this.parsedTime.interval instanceof RillIsoInterval) { + // TODO + } else { + void this.applyTimeRange(this.timeRange, tz); + } + }; + + public onSelectAsOfOption = ( + ref: RillTimeLabel | string | undefined, + inclusive: boolean, + ) => { + if (!this.timeRange) return; + const newString = constructNewString({ + currentString: this.timeRange, + truncationGrain: this.truncationGrain, + snapToEnd: ref === "watermark" ? false : inclusive, + ref, + }); + + return this.applyTimeRange(newString); + }; + + public onSelectTimeDimension = (timeDimension: string) => { + this.timeDimension = timeDimension; + if (this.timeRange) void this.applyTimeRange(this.timeRange); + }; + + private async applyTimeRange(newTimeRange: string, tz = this.timeZone) { + // If we don't have a valid time range, early return + if (!this.metricsViewsProvider.timeRangeSummary?.max) return; + + // This should be returned by the API, but it is not yet implemented + const includesTimeZoneOffset = newTimeRange.includes("tz"); + + if (includesTimeZoneOffset) { + const timeZone = newTimeRange.match(/tz (.*)/)?.[1]; + + if (timeZone) this.timeZone = timeZone; + } + + await queryClient.cancelQueries({ + predicate: (query) => + this.metricsViewsProvider.metricsViewNames.some((mvName) => + invalidationForMetricsViewData(query, mvName), + ), + }); + + const promises = this.metricsViewsProvider.metricsViewNames.map( + (mvName) => { + return deriveInterval( + newTimeRange, + this.runtimeClient, + mvName, + tz ?? "UTC", + this.timeDimension, + // executionTime, // TODO + ); + }, + ); + const intervals = await Promise.all(promises); + let latestInterval: Interval | undefined = undefined; + let smallestGrain: V1TimeGrain | undefined = undefined; + intervals.forEach(({ interval, grain }) => { + if ( + interval?.isValid && + interval.end && + (!latestInterval || latestInterval.end < interval.end) + ) { + latestInterval = interval; + } + + if ( + grain && + (!smallestGrain || + V1TimeGrainToOrder[grain] < V1TimeGrainToOrder[smallestGrain]) + ) { + smallestGrain = grain; + } + }); + if (!latestInterval) return; + + const allowedGrains = allowedGrainsForInterval( + latestInterval, + this.metricsViewsProvider.smallestTimeGrain ?? + V1TimeGrain.TIME_GRAIN_MINUTE, + ); + + const finalGrain = + this.timeGrain && allowedGrains.includes(this.timeGrain) + ? this.timeGrain + : smallestGrain && allowedGrains.includes(smallestGrain) + ? smallestGrain + : allowedGrains[0]; + + this.interval = latestInterval; + this.timeGrain = finalGrain; + } +} diff --git a/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte new file mode 100644 index 000000000000..2c8b129bf9cc --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte @@ -0,0 +1,498 @@ + + + { + if (e.metaKey && e.key === "k") { + open = !open; + } + }} +/> + + { + if (o) { + searchValue = timeString; + } + }} +> + + + {#snippet child({ props: tooltipProps })} + + {#snippet child({ props: popoverProps })} + + {/snippet} + + {/snippet} + + + + {#if interval} + + {/if} + + + + + { + open = false; + void onSelectRange(range); + }} + /> + +
+
+
+ {#if showDefaultItem && defaultTimeRange} + + {/if} + + + + + + + + void onSelectRange(r, true)} + /> + + {#if allTimeAllowed} +
+ +
+ {/if} +
+ + {#if allowCustomTimeRange} +
+
+ +
+ {/if} + + {#if !lockTimeZone} +
+
+ + + { + showCalendarPicker = false; + }} + class="group h-7 overflow-hidden hover:bg-popover-accent flex-none rounded-sm w-full select-none flex items-center truncate text-left gap-x-1 pr-1 pl-2" + > +
+ +
+
+ {m.dashboard_time_zone()} +
+
+ +
+ +
+ + + { + onSelectZone(z); + open = false; + timeZonePickerOpen = false; + }} + /> + +
+
+ {/if} + + {#if showTimeDimensionSelector && timeDimensions.length > 1} +
+
+ + + { + showCalendarPicker = false; + }} + aria-label={m.dashboard_select_time_axis()} + class="group h-7 overflow-hidden hover:bg-surface-hover flex-none rounded-sm w-full select-none flex items-center truncate text-left gap-x-1 pr-1 pl-2" + > +
+ +
+
{m.dashboard_time_axis()}
+ {#if activeTimeDimension} +
+ +
+ {/if} + +
+ + + {#each timeDimensions as { value, label, description } (value)} + + + {#snippet child({ props })} + + {/snippet} + + {#if description} + +
{label}
+
{description}
+
+ {/if} +
+ {/each} +
+
+
+ {/if} +
+ + {#if showCalendarPicker} +
+ { + if (searchValue) onSelectRange(searchValue); + }} + updateRange={(string) => { + searchValue = string; + }} + closeMenu={() => (open = false)} + /> +
+ {/if} +
+
+
+ +{#if dateTimeAnchor && !hideTruncationSelector} + { + onSelectAsOfOption(ref, inclusive); + }} + onSelectAsOfOption={(o) => { + onSelectAsOfOption(o, snapToEnd); + }} + /> +{/if} + + diff --git a/web-common/src/features/dashboards/time-controls/time-filters-config.ts b/web-common/src/features/dashboards/time-controls/time-filters-config.ts new file mode 100644 index 000000000000..9ef25df570c6 --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/time-filters-config.ts @@ -0,0 +1,14 @@ +export type TimeFiltersConfig = { + hidePan?: boolean; + canPanLeft?: boolean; + canPanRight?: boolean; + + showTimeDimensionSelector?: boolean; + allowCustomTimeRange?: boolean; + showDefaultItem: boolean; + lockTimeZone?: boolean; + showFullRange?: boolean; + showWatermark?: boolean; + + side: "top" | "right" | "bottom" | "left"; +}; diff --git a/web-common/src/features/dashboards/time-controls/time-range-utils.ts b/web-common/src/features/dashboards/time-controls/time-range-utils.ts index 1bb325c70e2a..3b02f08ccca6 100644 --- a/web-common/src/features/dashboards/time-controls/time-range-utils.ts +++ b/web-common/src/features/dashboards/time-controls/time-range-utils.ts @@ -3,63 +3,14 @@ * this file should be deprecated in favor of the other time utils. * * */ -import type { TimeRange } from "@rilldata/web-common/lib/time/types"; -import { V1TimeGrain } from "@rilldata/web-common/runtime-client"; -import { TimeRangeName_DEPRECATE } from "./time-control-types"; - +import { + type MetricsViewSpecDimension, + MetricsViewSpecDimensionType, + V1TimeGrain, +} from "@rilldata/web-common/runtime-client"; import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; import { durationToMillis } from "@rilldata/web-common/lib/time/grains"; -// May not need this anymore as using TimeGrain objects -export const supportedTimeGrainEnums = () => { - const supportedEnums: string[] = []; - const unsupportedTypes = [ - V1TimeGrain.TIME_GRAIN_UNSPECIFIED, - V1TimeGrain.TIME_GRAIN_MILLISECOND, - V1TimeGrain.TIME_GRAIN_SECOND, - ]; - - for (const timeGrain in V1TimeGrain) { - if (unsupportedTypes.includes(V1TimeGrain[timeGrain])) { - continue; - } - supportedEnums.push(timeGrain); - } - - return supportedEnums; -}; - -// Moved to time range and renamed to isTimeRangeValidForMinTimeGrain -export function isTimeRangeValidForTimeGrain( - minTimeGrain: V1TimeGrain, - timeRange: TimeRangeName_DEPRECATE, -): boolean { - const timeGrainEnums = Object.values(TIME_GRAIN).map( - (timeGrain) => timeGrain.grain, - ); - if (!timeGrainEnums.includes(minTimeGrain)) { - return true; - } - if (!timeRange || timeRange === TimeRangeName_DEPRECATE.ALL_TIME) { - return true; - } - - const timeRangeDurationMs = getLastXTimeRangeDurationMs(timeRange); - - const allowedTimeGrains = getAllowedTimeGrains(timeRangeDurationMs); - const maxAllowedTimeGrain = allowedTimeGrains[allowedTimeGrains.length - 1]; - return !isGrainBigger(minTimeGrain, maxAllowedTimeGrain); -} - -// Moved to time-grain and renamed -export function isGrainBigger( - grain1: V1TimeGrain, - grain2: V1TimeGrain, -): boolean { - if (grain1 === V1TimeGrain.TIME_GRAIN_UNSPECIFIED) return false; - return getTimeGrainDurationMs(grain1) > getTimeGrainDurationMs(grain2); -} - // Moved export function getAllowedTimeGrains(timeRangeDurationMs) { if ( @@ -142,97 +93,29 @@ export function getDefaultTimeGrain(start: Date, end: Date): V1TimeGrain { } } -// Not needed -export const timeGrainStringToEnum = (timeGrain: string): V1TimeGrain => { - switch (timeGrain) { - case "minute": - return V1TimeGrain.TIME_GRAIN_MINUTE; - case "hour": - return V1TimeGrain.TIME_GRAIN_HOUR; - case "day": - return V1TimeGrain.TIME_GRAIN_DAY; - case "week": - return V1TimeGrain.TIME_GRAIN_WEEK; - case "month": - return V1TimeGrain.TIME_GRAIN_MONTH; - case "year": - return V1TimeGrain.TIME_GRAIN_YEAR; - default: - return V1TimeGrain.TIME_GRAIN_UNSPECIFIED; - } -}; - -// Not needed -export const timeGrainEnumToYamlString = (timeGrain: V1TimeGrain): string => { - if (!timeGrain) return ""; - switch (timeGrain) { - case V1TimeGrain.TIME_GRAIN_MINUTE: - return "minute"; - case V1TimeGrain.TIME_GRAIN_HOUR: - return "hour"; - case V1TimeGrain.TIME_GRAIN_DAY: - return "day"; - case V1TimeGrain.TIME_GRAIN_WEEK: - return "week"; - case V1TimeGrain.TIME_GRAIN_MONTH: - return "month"; - case V1TimeGrain.TIME_GRAIN_YEAR: - return "year"; - default: - return timeGrain; - } -}; - -// This is the wrong way to deal with this. We should be (1) calculating the time range first -// then (2) getting the exact duration. -const getLastXTimeRangeDurationMs = (name: TimeRangeName_DEPRECATE): number => { - switch (name) { - case TimeRangeName_DEPRECATE.LAST_SIX_HOURS: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_HOUR.duration) * 6; - case TimeRangeName_DEPRECATE.LAST_24_HOURS: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration); - case TimeRangeName_DEPRECATE.LAST_7_DAYS: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 7; - case TimeRangeName_DEPRECATE.LAST_4_WEEKS: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 28; - - default: - throw new Error(`Unknown last X time range name: ${name}`); - } -}; +export function getTimeDimensionOptions( + dimensions: MetricsViewSpecDimension[], + restrictedDimensions: string[] | undefined, +) { + const timeDimensions = dimensions.filter( + (d) => + d.type === MetricsViewSpecDimensionType.DIMENSION_TYPE_TIME && + (!restrictedDimensions || restrictedDimensions.includes(d.name!)), + ); -// map from time grain to duration in ms. -const getTimeGrainDurationMs = (timeGrain: V1TimeGrain): number => { - switch (timeGrain) { - case V1TimeGrain.TIME_GRAIN_MINUTE: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_MINUTE.duration); - case V1TimeGrain.TIME_GRAIN_HOUR: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_HOUR.duration); - case V1TimeGrain.TIME_GRAIN_DAY: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration); - case V1TimeGrain.TIME_GRAIN_WEEK: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 7; - case V1TimeGrain.TIME_GRAIN_MONTH: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 30; - case V1TimeGrain.TIME_GRAIN_YEAR: - return durationToMillis(TIME_GRAIN.TIME_GRAIN_YEAR.duration); - default: - throw new Error(`Unknown time grain: ${timeGrain}`); + if (restrictedDimensions) { + timeDimensions.sort( + (a, b) => + restrictedDimensions.indexOf(a.name!) - + restrictedDimensions.indexOf(b.name!), + ); } -}; -// might not need it -export function makeRelativeTimeRange( - timeRangeName: TimeRangeName_DEPRECATE, - allTimeRange: TimeRange, -): TimeRange { - if (timeRangeName === TimeRangeName_DEPRECATE.ALL_TIME) return allTimeRange; - const startTime = new Date( - allTimeRange.end.getTime() - getLastXTimeRangeDurationMs(timeRangeName), - ); - return { - name: timeRangeName, - start: startTime, - end: allTimeRange.end, - }; + return timeDimensions.map((timeDim) => { + return { + value: timeDim.name!, + label: timeDim.displayName || timeDim.name!, + description: timeDim.description, + }; + }); } 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..5d74219d2d47 --- /dev/null +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -0,0 +1,279 @@ +import { + createQueryServiceMetricsViewTimeRange, + createRuntimeServiceListResources, + type MetricsViewSpecDimension, + type MetricsViewSpecMeasure, + type V1MetricsViewSpec, + type V1Resource, + V1TimeGrain, + type V1TimeRangeSummary, +} from "@rilldata/web-common/runtime-client"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { isSimpleMeasure } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures.ts"; +import { Duration } from "luxon"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; +import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors.ts"; +import { arrayUnorderedEquals } from "@rilldata/web-common/lib/arrayUtils.ts"; +import { V1TimeGrainToOrder } from "@rilldata/web-common/lib/time/new-grains.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; + /** Smallest time grain across the metrics views, since it has to hold for all of them. */ + public smallestTimeGrain: V1TimeGrain | undefined; + public smallestGrainOrder: number; + /** 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; + + private resources: V1Resource[] = []; + private readonly timeRangeUnsubs = new Map void>(); + + public constructor( + public readonly runtimeClient: RuntimeClient, + initMetricsViewNames: string[], + ) { + this.metricsViewNames = initMetricsViewNames.filter(Boolean); + + 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.length > 0 && + 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[]) { + metricsViewNames = metricsViewNames.filter(Boolean); + if (arrayUnorderedEquals(this.metricsViewNames, metricsViewNames)) return; + 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[] = []; + + let smallestTimeGrain: V1TimeGrain | undefined = undefined; + let smallestGrainOrder: number | undefined = Infinity; + + for (const metricsViewName of this.metricsViewNames) { + const res = this.resources.find( + (resource) => + resource.meta?.name?.name === metricsViewName && + resource.meta?.name?.kind === ResourceKind.MetricsView, + ); + const spec = res?.metricsView?.state?.validSpec; + if (!spec) continue; + specs[metricsViewName] = spec; + + spec.measures?.forEach((measure) => { + if (!measure.name) return; + + let specsForMeasure = measureSpecs[measure.name]; + if (!specsForMeasure) { + specsForMeasure = measureSpecs[measure.name] = {}; + measures.push(measure); + if (isSimpleMeasure(measure)) simpleMeasures.push(measure); + } + specsForMeasure[metricsViewName] = measure; + }); + + spec.dimensions?.forEach((dimension) => { + // Filter expressions identify an unnamed dimension by its column. + const dimensionName = dimension.name || dimension.column; + if (!dimensionName) return; + + let specsForDimension = dimensionSpecs[dimensionName]; + if (!specsForDimension) { + specsForDimension = dimensionSpecs[dimensionName] = {}; + dimensions.push(dimension); + } + specsForDimension[metricsViewName] = dimension; + }); + + if (spec.smallestTimeGrain) { + const specGrainOrder = V1TimeGrainToOrder[spec.smallestTimeGrain]; + + if (!smallestTimeGrain) { + smallestTimeGrain = spec.smallestTimeGrain; + smallestGrainOrder = specGrainOrder; + } else if (specGrainOrder < smallestGrainOrder) { + smallestTimeGrain = spec.smallestTimeGrain; + smallestGrainOrder = specGrainOrder; + } + } + + this.subscribeToTimeRange(metricsViewName, spec); + } + + this.specs = specs; + this.measureSpecs = measureSpecs; + this.measures = measures; + this.simpleMeasures = simpleMeasures; + this.dimensionSpecs = dimensionSpecs; + this.dimensions = dimensions; + this.smallestTimeGrain = smallestTimeGrain; + this.smallestGrainOrder = smallestTimeGrain + ? smallestGrainOrder + : V1TimeGrainToOrder[V1TimeGrain.TIME_GRAIN_MINUTE]; + } + + /** + * 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, + ); + }), + ); + } +} From 257558ce7b5115542b006e9cb5ccbad316895503 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 26 Aug 2026 18:40:18 +0530 Subject: [PATCH 02/15] Add to explore dashboard --- .../dashboards/filters/Filters.svelte | 418 +----------------- .../DashboardConfigProvider.svelte.ts | 1 + .../state-managers/state-managers.ts | 25 ++ .../ComparisonTimeRangeManager.svelte.ts | 34 +- .../ComparisonTimeRangePicker.svelte | 15 +- .../time-controls/TimeFilterManager.svelte.ts | 49 +- .../time-controls/TimeFilters.svelte | 22 +- .../time-controls/TimeRangeManager.svelte.ts | 85 +++- .../time-controls/TimeRangePicker.svelte | 46 +- .../time-controls/time-filters-config.ts | 2 +- .../dashboards/workspace/Dashboard.svelte | 4 +- 11 files changed, 242 insertions(+), 459 deletions(-) diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index a047aebf6125..04819b63ce64 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -1,60 +1,25 @@
comparisonTimeRangeManager.onToggleShowComparison()} type="button" aria-label={m.dashboard_toggle_time_comparison_aria()} > @@ -140,10 +142,7 @@ {@const selected = selectedLabel === option.name} { - onSelectComparisonRange(option.name); - open = false; - }} + onclick={() => onSelectComparisonRange(option.name)} > {preset?.label || option.name} diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts index ee3c01ffb28c..e7f7e39f63db 100644 --- a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts @@ -3,6 +3,8 @@ import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics import { TimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeRangeManager.svelte.ts"; import { ComparisonTimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts"; import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; +import { page } from "$app/state"; +import { goto } from "$app/navigation"; export class TimeFilterManager { public timeRangeManager: TimeRangeManager; @@ -10,7 +12,7 @@ export class TimeFilterManager { public constructor( runtimeClient: RuntimeClient, - metricsViewsProvider: MetricsViewsProvider, + private readonly metricsViewsProvider: MetricsViewsProvider, yamlConfigProvider: YAMLConfigProvider, allowCustomTimeRange: boolean, ) { @@ -24,4 +26,49 @@ export class TimeFilterManager { allowCustomTimeRange, ); } + + public syncWithUrl( + defaultUrlParamsGetter: () => URLSearchParams | undefined, + ) { + let lock = false; + + $effect(() => { + if (!this.metricsViewsProvider.ready || lock) return; + lock = true; + + const newUrlSearch = new URLSearchParams(page.url.searchParams); + if (defaultUrlParamsGetter) { + const defaultUrlParams = defaultUrlParamsGetter(); + if (defaultUrlParams) { + defaultUrlParams.forEach((value, key) => { + newUrlSearch.set(key, value); + }); + } + } + + this.timeRangeManager.setUrlParams(newUrlSearch); + this.comparisonTimeRangeManager.setUrlParams(newUrlSearch); + + lock = false; + }); + + $effect(() => { + if (!this.metricsViewsProvider.ready || lock) return; + lock = true; + + const newUrl = new URL(page.url); + this.timeRangeManager.applyFilterToParams(newUrl.searchParams); + this.comparisonTimeRangeManager.applyFilterToParams(newUrl.searchParams); + + if (newUrl.search === page.url.search) { + lock = false; + return; + } + + void goto(newUrl).then( + () => (lock = false), + () => (lock = false), + ); + }); + } } diff --git a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte index e930546e1485..b6122892a69f 100644 --- a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte +++ b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte @@ -4,34 +4,36 @@ import { Nudge } from "@rilldata/web-common/features/dashboards/time-controls/super-pill/components"; import TimeRangePicker from "@rilldata/web-common/features/dashboards/time-controls/TimeRangePicker.svelte"; import type { TimeFiltersConfig } from "@rilldata/web-common/features/dashboards/time-controls/time-filters-config.ts"; - import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import Calendar from "@rilldata/web-common/components/icons/Calendar.svelte"; import Metadata from "@rilldata/web-common/features/dashboards/time-controls/super-pill/components/Metadata.svelte"; import ComparisonTimeRangePicker from "@rilldata/web-common/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte"; - import { type V1ExploreTimeRange } from "@rilldata/web-common/runtime-client"; - import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; + import type { DashboardConfigProvider } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; let { timeFilterManager, - metricsViewsProvider, - yamlConfigProvider, + dashboardConfigProvider, + defaultUrlParams, context, config, }: { timeFilterManager: TimeFilterManager; - metricsViewsProvider: MetricsViewsProvider; - yamlConfigProvider: YAMLConfigProvider; - dimensions: string[]; - timeRanges: V1ExploreTimeRange[]; + dashboardConfigProvider: DashboardConfigProvider; + defaultUrlParams?: URLSearchParams; context: string; config: TimeFiltersConfig; } = $props(); - let hidePan = $derived(config.hidePan); + let hidePan = $derived(config.hidePan ?? false); let canPanLeft = $derived(config.canPanLeft ?? !hidePan); let canPanRight = $derived(config.canPanRight ?? !hidePan); let timeRangeManager = $derived(timeFilterManager.timeRangeManager); let { timeZone, minDate, maxDate } = $derived(timeRangeManager); + // svelte-ignore state_referenced_locally + timeFilterManager.syncWithUrl(() => defaultUrlParams); + + let { metricsViewsProvider, yamlConfigProvider } = $derived( + dashboardConfigProvider, + ); let comparisonTimeRangeManager = $derived( timeFilterManager.comparisonTimeRangeManager, diff --git a/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts index 285e4b625f7d..1d4d5f4c08dd 100644 --- a/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts +++ b/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts @@ -25,17 +25,21 @@ import { import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; import { invalidationForMetricsViewData } from "@rilldata/web-common/runtime-client/invalidation.ts"; import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import { FromURLParamTimeDimensionMap } from "@rilldata/web-common/features/dashboards/url-state/mappers.ts"; + +const DefaultTimeZone = "UTC"; export class TimeRangeManager { public timeRange = $state(undefined); public timeGrain = $state(undefined); - public timeZone = $state("UTC"); + public timeZone = $state(DefaultTimeZone); public timeDimension = $state(undefined); + public interval = $state(undefined); public minDate: DateTime | undefined; public maxDate: DateTime | undefined; - public interval: Interval | undefined; public parsedTime: RillTime | undefined; public truncationGrain: V1TimeGrain | undefined; public ref: RillTimeLabel | string | undefined; @@ -81,7 +85,56 @@ export class TimeRangeManager { ); } - public onSelectRange = (range: string, ignoreSnap?: boolean) => { + public setUrlParams(searchParams: URLSearchParams) { + this.timeGrain = + FromURLParamTimeDimensionMap[ + searchParams.get(ExploreStateURLParams.TimeGrain)! + ] ?? undefined; + + this.timeZone = + searchParams.get(ExploreStateURLParams.TimeZone) ?? DefaultTimeZone; + + this.timeDimension = + searchParams.get(ExploreStateURLParams.TimeDimension) ?? undefined; + + if (searchParams.has(ExploreStateURLParams.TimeRange)) { + void this.onSelectRange( + searchParams.get(ExploreStateURLParams.TimeRange)!, + true, + ); + } else { + this.timeRange = undefined; + this.interval = undefined; + } + } + + public applyFilterToParams(searchParams: URLSearchParams) { + if (this.timeRange) { + searchParams.set(ExploreStateURLParams.TimeRange, this.timeRange); + } else { + searchParams.delete(ExploreStateURLParams.TimeRange); + } + + if (this.timeGrain) { + searchParams.set(ExploreStateURLParams.TimeGrain, this.timeGrain); + } else { + searchParams.delete(ExploreStateURLParams.TimeGrain); + } + + if (this.timeZone !== DefaultTimeZone) { + searchParams.set(ExploreStateURLParams.TimeZone, this.timeZone); + } else { + searchParams.delete(ExploreStateURLParams.TimeZone); + } + + if (this.timeDimension) { + searchParams.set(ExploreStateURLParams.TimeDimension, this.timeDimension); + } else { + searchParams.delete(ExploreStateURLParams.TimeDimension); + } + } + + public onSelectRange(range: string, ignoreSnap?: boolean) { try { const parsed = parseRillTime(range); @@ -126,9 +179,9 @@ export class TimeRangeManager { } catch { // This function is called in a controlled manner and should not throw } - }; + } - public onSelectGrain = (grain: V1TimeGrain | undefined) => { + public onSelectGrain(grain: V1TimeGrain | undefined) { if (!this.timeRange) return; const newString = constructNewString({ @@ -139,9 +192,9 @@ export class TimeRangeManager { }); return this.applyTimeRange(newString); - }; + } - public onSelectZone = (tz: string) => { + public onSelectZone(tz: string) { this.timeZone = tz; if (!this.timeRange || !this.parsedTime) return; @@ -150,12 +203,12 @@ export class TimeRangeManager { } else { void this.applyTimeRange(this.timeRange, tz); } - }; + } - public onSelectAsOfOption = ( + public onSelectAsOfOption( ref: RillTimeLabel | string | undefined, inclusive: boolean, - ) => { + ) { if (!this.timeRange) return; const newString = constructNewString({ currentString: this.timeRange, @@ -165,16 +218,20 @@ export class TimeRangeManager { }); return this.applyTimeRange(newString); - }; + } - public onSelectTimeDimension = (timeDimension: string) => { + public onSelectTimeDimension(timeDimension: string) { this.timeDimension = timeDimension; if (this.timeRange) void this.applyTimeRange(this.timeRange); - }; + } private async applyTimeRange(newTimeRange: string, tz = this.timeZone) { + console.log("applyTimeRange", newTimeRange); // If we don't have a valid time range, early return - if (!this.metricsViewsProvider.timeRangeSummary?.max) return; + if (!this.metricsViewsProvider.timeRangeSummary?.max) { + console.log("No summary"); + return; + } // This should be returned by the API, but it is not yet implemented const includesTimeZoneOffset = newTimeRange.includes("tz"); diff --git a/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte index 2c8b129bf9cc..c87a657cbd95 100644 --- a/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte +++ b/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte @@ -72,12 +72,6 @@ truncationGrain, ref, snapToEnd, - - onSelectRange, - onSelectZone, - onSelectGrain, - onSelectAsOfOption, - onSelectTimeDimension, } = $derived(timeRangeManager); let { smallestTimeGrain, maxQueryTimeRange } = $derived(metricsViewsProvider); @@ -148,6 +142,23 @@ return DateTime.now().setZone(zone); } } + + function onSelectRange(range: string, ignoreSnap?: boolean) { + open = false; + void timeRangeManager.onSelectRange(range, ignoreSnap); + } + + function onSelectTimeZone(zone: string) { + open = false; + timeZonePickerOpen = false; + timeRangeManager.onSelectZone(zone); + } + + function onSelectTimeDimension(dim: string) { + open = false; + timeAxisPickerOpen = false; + timeRangeManager.onSelectTimeDimension(dim); + } { - open = false; - void onSelectRange(range); - }} + {onSelectRange} />
{ - onSelectZone(z); - open = false; - timeZonePickerOpen = false; - }} + {onSelectTimeZone} /> @@ -409,11 +413,7 @@ aria-label={m.dashboard_select_time_dimension({ label, })} - onclick={() => { - onSelectTimeDimension(value); - open = false; - timeAxisPickerOpen = false; - }} + onclick={() => onSelectTimeDimension(value)} > {label} {#if value === (timeDimension || primaryTimeDimension)} @@ -477,12 +477,12 @@ {snapToEnd} {ref} zone={timeZone} - onSelectEnding={onSelectGrain} + onSelectEnding={(g) => timeRangeManager.onSelectGrain(g)} onToggleAlignment={(inclusive) => { - onSelectAsOfOption(ref, inclusive); + timeRangeManager.onSelectAsOfOption(ref, inclusive); }} onSelectAsOfOption={(o) => { - onSelectAsOfOption(o, snapToEnd); + timeRangeManager.onSelectAsOfOption(o, snapToEnd); }} /> {/if} diff --git a/web-common/src/features/dashboards/time-controls/time-filters-config.ts b/web-common/src/features/dashboards/time-controls/time-filters-config.ts index 9ef25df570c6..2189946504d9 100644 --- a/web-common/src/features/dashboards/time-controls/time-filters-config.ts +++ b/web-common/src/features/dashboards/time-controls/time-filters-config.ts @@ -10,5 +10,5 @@ export type TimeFiltersConfig = { showFullRange?: boolean; showWatermark?: boolean; - side: "top" | "right" | "bottom" | "left"; + side?: "top" | "right" | "bottom" | "left"; }; diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 2e78e25f02a6..9a5347082b72 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -133,8 +133,6 @@ } : undefined; - $: timeRanges = exploreSpec?.timeRanges ?? []; - $: visibleMeasureNames = $visibleMeasures.map(({ name }) => name ?? ""); // For non-embedded dashboards, theme can come from URL params. @@ -173,7 +171,7 @@ {:else} {#key exploreName}
- +
From 8fde44fba97ed017d7aeffcd6ec874f836002396 Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Thu, 27 Aug 2026 18:01:34 +0530 Subject: [PATCH 03/15] Add sync code --- .../dashboards/filters/Filters.svelte | 11 ++- .../loaders/DashboardStateSync.ts | 10 ++ .../dashboards/stores/dashboard-stores.ts | 28 ++++++ .../ComparisonTimeRangeManager.svelte.ts | 32 ++++++ .../ComparisonTimeRangePicker.svelte | 1 + .../time-controls/TimeFilterManager.svelte.ts | 66 +++++-------- .../time-controls/TimeFilters.svelte | 29 ++++-- .../time-controls/TimeRangeManager.svelte.ts | 49 ++++++++-- .../url-params-store-sync.svelte.ts | 97 +++++++++++++++++++ web-common/src/lib/time/ranges/index.ts | 37 ++++++- web-common/src/lib/url-utils.ts | 9 ++ 11 files changed, 310 insertions(+), 59 deletions(-) create mode 100644 web-common/src/lib/store-utils/url-params-store-sync.svelte.ts diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 04819b63ce64..96290dd49743 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -18,6 +18,7 @@ import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import TimeFilters from "@rilldata/web-common/features/dashboards/time-controls/TimeFilters.svelte"; import { createRillDefaultExploreUrlParams } from "@rilldata/web-common/features/dashboards/url-state/get-rill-default-explore-url-params.ts"; + import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores.ts"; export let readOnly = false; export let metricsViewName: string; @@ -28,6 +29,7 @@ const StateManagers = getStateManagers(); const { + exploreName, actions: { dimensionsFilter: { toggleMultipleDimensionValueSelections, @@ -100,6 +102,7 @@ validSpecStore as any, timeRangeSummaryStore as any, ); + $: defaultUrlParams = $defaultUrlParamsStore.data; function handleMeasureFilterApply( dimension: string, @@ -128,6 +131,11 @@ const url = dashboardStateSync.getUrlForExploreState(exploreState); return isUrlTooLong(url); } + + function syncTimeFilters() { + metricsExplorerStore.syncTimeFilters($exploreName, timeFilterManager); + return Promise.resolve(); + }
@@ -135,7 +143,7 @@ {/if} 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..173a25ab6ed0 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -294,6 +294,7 @@ export class DashboardStateSync { this.updating = false; } + log("URL", redirectUrl); // If the url doesn't need to be changed further then we can skip the goto if (redirectUrl.search === pageState.url.search) { return; @@ -349,6 +350,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 +364,11 @@ export class DashboardStateSync { } } } + +function log(label: string, toUrl: URL) { + const fromUrlSearch = get(page).url.search; + const areEqual = fromUrlSearch === toUrl.search; + console.log( + `[${label}] ${fromUrlSearch} =${areEqual ? "x" : "="}> ${toUrl.search}`, + ); +} diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index 6beecd0c54b3..d0bf02c34cb5 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 { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; export interface MetricsExplorerStoreType { entities: Record; @@ -237,6 +238,33 @@ const metricsViewReducers = { }); }, + syncTimeFilters(name: string, timeFilterManager: TimeFilterManager) { + if (!name) return; + updateMetricsExplorerByName(name, (exploreState) => { + exploreState.selectedTimeRange = { + name: timeFilterManager.timeRangeManager.timeRange, + start: + timeFilterManager.timeRangeManager.interval?.start?.toJSDate() ?? + new Date(), + end: + timeFilterManager.timeRangeManager.interval?.end?.toJSDate() ?? + new Date(), + interval: timeFilterManager.timeRangeManager.timeGrain, + } as any; + exploreState.showTimeComparison = + timeFilterManager.comparisonTimeRangeManager.showComparison; + exploreState.selectedComparisonTimeRange = { + name: timeFilterManager.comparisonTimeRangeManager.comparisonTimeRange, + start: + timeFilterManager.comparisonTimeRangeManager.interval?.start?.toJSDate() ?? + new Date(), + end: + timeFilterManager.comparisonTimeRangeManager.interval?.end?.toJSDate() ?? + new Date(), + }; + }); + }, + setPivotMode(name: string, mode: boolean) { updateMetricsExplorerByName(name, (exploreState) => { if (mode) { diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts index 574cdfa9c3c7..9bd97479dddf 100644 --- a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts +++ b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts @@ -12,6 +12,8 @@ import { import { TimeComparisonOption } from "@rilldata/web-common/lib/time/types.ts"; import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import { copySubsetParams } from "@rilldata/web-common/lib/url-utils.ts"; +import { getAdjustedInterval } from "@rilldata/web-common/lib/time/ranges"; type ComparisonTimeRangeOption = { name: TimeComparisonOption; @@ -19,15 +21,23 @@ type ComparisonTimeRangeOption = { interval: Interval; }; +const ComparisonTimeRangeParams = new Set([ + ExploreStateURLParams.ComparisonTimeRange, +]); + export class ComparisonTimeRangeManager { public comparisonTimeRange = $state(undefined); public showComparison = $state(false); public interval = $state(undefined); + public adjustedInterval = $state(undefined); public comparisonTimeRangeOptions: ComparisonTimeRangeOption[]; public parsedTime: RillTime | undefined; + public curStateParams = $state(new URLSearchParams()); + public curSetParams = $state(new URLSearchParams()); + public constructor( private readonly yamlConfigProvider: YAMLConfigProvider, private readonly timeRangeManager: TimeRangeManager, @@ -47,7 +57,21 @@ export class ComparisonTimeRangeManager { }); } + public createListener() { + $effect(() => { + const newParams = new URLSearchParams(); + this.applyFilterToParams(newParams); + if (newParams.toString() === this.curStateParams.toString()) return; + this.curStateParams = newParams; + }); + } + public setUrlParams(searchParams: URLSearchParams) { + this.curSetParams = copySubsetParams( + searchParams, + ComparisonTimeRangeParams, + ); + if (searchParams.has(ExploreStateURLParams.ComparisonTimeRange)) { this.showComparison = true; void this.onSelectComparisonRange( @@ -89,6 +113,13 @@ export class ComparisonTimeRangeManager { range, this.timeRangeManager.timeZone, ); + this.adjustedInterval = this.interval + ? getAdjustedInterval( + this.interval, + this.timeRangeManager.timeGrain, + this.timeRangeManager.timeZone, + ) + : undefined; } } catch { return undefined; @@ -100,6 +131,7 @@ export class ComparisonTimeRangeManager { } private getComparisonTimeRangeOptions() { + // Type-safety if ( !this.timeRangeManager.minDate || !this.timeRangeManager.maxDate || diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte index 8d8b14fb7a20..2cce111f4db6 100644 --- a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte +++ b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte @@ -112,6 +112,7 @@ aria-disabled={disabled} aria-label={m.dashboard_select_time_comparison_aria()} type="button" + class="rounded-r-full" >
{#if !comparisonTimeRangeOptions.length && !showComparison} diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts index e7f7e39f63db..7d202e1e5305 100644 --- a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts @@ -3,16 +3,18 @@ import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics import { TimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeRangeManager.svelte.ts"; import { ComparisonTimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts"; import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; -import { page } from "$app/state"; -import { goto } from "$app/navigation"; +import { copyParamsToTarget } from "@rilldata/web-common/lib/url-utils.ts"; export class TimeFilterManager { public timeRangeManager: TimeRangeManager; public comparisonTimeRangeManager: ComparisonTimeRangeManager; + public curStateParams = $state(new URLSearchParams()); + public curSetParams = $state(new URLSearchParams()); + public constructor( runtimeClient: RuntimeClient, - private readonly metricsViewsProvider: MetricsViewsProvider, + metricsViewsProvider: MetricsViewsProvider, yamlConfigProvider: YAMLConfigProvider, allowCustomTimeRange: boolean, ) { @@ -27,48 +29,30 @@ export class TimeFilterManager { ); } - public syncWithUrl( - defaultUrlParamsGetter: () => URLSearchParams | undefined, - ) { - let lock = false; + public createListener() { + this.timeRangeManager.createListener(); + this.comparisonTimeRangeManager.createListener(); $effect(() => { - if (!this.metricsViewsProvider.ready || lock) return; - lock = true; - - const newUrlSearch = new URLSearchParams(page.url.searchParams); - if (defaultUrlParamsGetter) { - const defaultUrlParams = defaultUrlParamsGetter(); - if (defaultUrlParams) { - defaultUrlParams.forEach((value, key) => { - newUrlSearch.set(key, value); - }); - } - } - - this.timeRangeManager.setUrlParams(newUrlSearch); - this.comparisonTimeRangeManager.setUrlParams(newUrlSearch); - - lock = false; + const newParams = new URLSearchParams(); + this.timeRangeManager.applyFilterToParams(newParams); + this.comparisonTimeRangeManager.applyFilterToParams(newParams); + if (newParams.toString() === this.curStateParams.toString()) return; + this.curStateParams = newParams; }); + } - $effect(() => { - if (!this.metricsViewsProvider.ready || lock) return; - lock = true; - - const newUrl = new URL(page.url); - this.timeRangeManager.applyFilterToParams(newUrl.searchParams); - this.comparisonTimeRangeManager.applyFilterToParams(newUrl.searchParams); - - if (newUrl.search === page.url.search) { - lock = false; - return; - } + public setUrlParams(urlParams: URLSearchParams) { + this.timeRangeManager.setUrlParams(urlParams); + this.comparisonTimeRangeManager.setUrlParams(urlParams); - void goto(newUrl).then( - () => (lock = false), - () => (lock = false), - ); - }); + const newSetParams = new URLSearchParams( + this.timeRangeManager.curSetParams, + ); + copyParamsToTarget( + this.comparisonTimeRangeManager.curSetParams, + newSetParams, + ); + this.curSetParams = newSetParams; } } diff --git a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte index b6122892a69f..840fd47dfb57 100644 --- a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte +++ b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte @@ -8,6 +8,7 @@ import Metadata from "@rilldata/web-common/features/dashboards/time-controls/super-pill/components/Metadata.svelte"; import ComparisonTimeRangePicker from "@rilldata/web-common/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte"; import type { DashboardConfigProvider } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; + import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; let { timeFilterManager, @@ -15,26 +16,36 @@ defaultUrlParams, context, config, + syncTimeFilters, }: { timeFilterManager: TimeFilterManager; dashboardConfigProvider: DashboardConfigProvider; defaultUrlParams?: URLSearchParams; context: string; config: TimeFiltersConfig; + syncTimeFilters: () => Promise; } = $props(); let hidePan = $derived(config.hidePan ?? false); let canPanLeft = $derived(config.canPanLeft ?? !hidePan); let canPanRight = $derived(config.canPanRight ?? !hidePan); + // svelte-ignore state_referenced_locally + timeFilterManager.createListener(); let timeRangeManager = $derived(timeFilterManager.timeRangeManager); let { timeZone, minDate, maxDate } = $derived(timeRangeManager); - // svelte-ignore state_referenced_locally - timeFilterManager.syncWithUrl(() => defaultUrlParams); let { metricsViewsProvider, yamlConfigProvider } = $derived( dashboardConfigProvider, ); + // svelte-ignore state_referenced_locally + syncStoreWithSource( + timeFilterManager, + syncTimeFilters, + () => metricsViewsProvider.ready, + () => defaultUrlParams, + ); + let comparisonTimeRangeManager = $derived( timeFilterManager.comparisonTimeRangeManager, ); @@ -71,14 +82,14 @@ {context} {config} /> - -
+ +
diff --git a/web-common/src/features/dashboards/time-controls/ComparisonSelector.svelte b/web-common/src/features/dashboards/time-controls/comparison/ComparisonSelector.svelte similarity index 97% rename from web-common/src/features/dashboards/time-controls/ComparisonSelector.svelte rename to web-common/src/features/dashboards/time-controls/comparison/ComparisonSelector.svelte index fb776eb10154..df1a280b2a45 100644 --- a/web-common/src/features/dashboards/time-controls/ComparisonSelector.svelte +++ b/web-common/src/features/dashboards/time-controls/comparison/ComparisonSelector.svelte @@ -3,9 +3,9 @@ import { Search } from "@rilldata/web-common/components/search"; import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte"; import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte"; - import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; - import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; - import { getNoComparisonLabel } from "@rilldata/web-common/lib/time/config"; + import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers.ts"; + import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores.ts"; + import { getNoComparisonLabel } from "@rilldata/web-common/lib/time/config.ts"; import type { MetricsViewSpecDimension } from "@rilldata/web-common/runtime-client"; import { matchSorter } from "match-sorter"; import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu"; diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte similarity index 100% rename from web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte rename to web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte diff --git a/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte b/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte deleted file mode 100644 index 8787e5365795..000000000000 --- a/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - -
- {#if !hidePan} - - - {/if} - - {#if $newPicker} - - {:else if interval && activeTimeGrain} - { - applyRange({ - name: TimeRangePreset.CUSTOM, - start: interval.start.toJSDate(), - end: interval.end.toJSDate(), - }); - }} - {onSelectRange} - /> - {/if} - - {#if availableTimeZones.length && !$newPicker} - - {/if} - - {#if !$newPicker && !showPivot && minTimeGrain} - - {/if} -
- - diff --git a/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte similarity index 100% rename from web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte rename to web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/RangePicker.svelte b/web-common/src/features/dashboards/time-controls/super-pill/components/RangePicker.svelte deleted file mode 100644 index 7ba52e612c0a..000000000000 --- a/web-common/src/features/dashboards/time-controls/super-pill/components/RangePicker.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - - { - showSelector = selected === "CUSTOM"; - }} -> - - {#snippet child({ props })} - - {/snippet} - - -
-
- { - onSelectRange(selected); - - open = false; - }} - onSelectCustomOption={() => (showSelector = !showSelector)} - /> -
- {#if showSelector} -
- { - applyCustomRange(i); - }} - closeMenu={() => (open = false)} - /> -
- {/if} -
-
-
diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts b/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts index f5be8c7d77f7..5e01863a5648 100644 --- a/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts +++ b/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts @@ -2,6 +2,5 @@ import Comparison from "./Comparison.svelte"; import Nudge from "./Nudge.svelte"; import Zone from "./Zone.svelte"; import Zoom from "./Zoom.svelte"; -import RangePicker from "./RangePicker.svelte"; -export { Comparison, Nudge, Zone, Zoom, RangePicker }; +export { Comparison, Nudge, Zone, Zoom }; diff --git a/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/RangePickerV2.svelte b/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/RangePickerV2.svelte deleted file mode 100644 index 014a4731fbb1..000000000000 --- a/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/RangePickerV2.svelte +++ /dev/null @@ -1,578 +0,0 @@ - - - { - if (e.metaKey && e.key === "k") { - open = !open; - } - }} -/> - - { - if (o) { - searchValue = timeString; - } - }} -> - - - {#snippet child({ props: tooltipProps })} - - {#snippet child({ props: popoverProps })} - - {/snippet} - - {/snippet} - - - - {#if interval} - - {/if} - - - - - { - open = false; - - handleRangeSelect(range); - }} - /> - -
-
-
- {#if showDefaultItem && defaultTimeRange} - - {/if} - - - - - - - - { - handleRangeSelect(r, true); - }} - /> - - {#if allTimeAllowed} -
- -
- {/if} -
- - {#if allowCustomTimeRange} -
-
- -
- {/if} - - {#if !lockTimeZone} -
-
- - - { - showCalendarPicker = false; - }} - class="group h-7 overflow-hidden hover:bg-popover-accent flex-none rounded-sm w-full select-none flex items-center truncate text-left gap-x-1 pr-1 pl-2" - > -
- -
-
- {m.dashboard_time_zone()} -
-
- -
- -
- - - { - onSelectTimeZone(z); - closeMenu(); - timeZonePickerOpen = false; - }} - /> - -
-
- {/if} - - {#if timeDimensions.length > 1 && onTimeDimensionSelect} -
-
- - - { - showCalendarPicker = false; - }} - aria-label={m.dashboard_select_time_axis()} - class="group h-7 overflow-hidden hover:bg-surface-hover flex-none rounded-sm w-full select-none flex items-center truncate text-left gap-x-1 pr-1 pl-2" - > -
- -
-
{m.dashboard_time_axis()}
- {#if activeTimeDimension} -
- -
- {/if} - -
- - - {#each timeDimensions as { value, label, description } (value)} - - - {#snippet child({ props })} - - {/snippet} - - {#if description} - -
{label}
-
{description}
-
- {/if} -
- {/each} -
-
-
- {/if} -
- - {#if showCalendarPicker} -
- { - if (searchValue) handleRangeSelect(searchValue); - }} - updateRange={(string) => { - searchValue = string; - }} - closeMenu={() => (open = false)} - /> -
- {/if} -
-
-
- -{#if dateTimeAnchor && !hideTruncationSelector} - { - onSelectAsOfOption(ref, inclusive); - }} - onSelectAsOfOption={(o) => { - onSelectAsOfOption(o, snapToEnd); - }} - /> -{/if} - - diff --git a/web-common/src/features/dashboards/time-controls/time-range-mappers.ts b/web-common/src/features/dashboards/time-controls/time-range-mappers.ts index 9d37e8bc455a..64c68ab1382d 100644 --- a/web-common/src/features/dashboards/time-controls/time-range-mappers.ts +++ b/web-common/src/features/dashboards/time-controls/time-range-mappers.ts @@ -239,9 +239,9 @@ export function mapV1TimeRangeToSelectedComparisonTimeRange( } export function mapTimeManagerRangeToV1TimeRange( - timeFilterManager: TimeFilterManager, + timeFilterManager: TimeFilterManager | undefined, ): V1TimeRange | undefined { - if (!timeFilterManager.timeRange) return; + if (!timeFilterManager?.timeRange) return undefined; if (timeFilterManager.parsedTime) { return { expression: timeFilterManager.timeRange, @@ -278,11 +278,12 @@ export function mapTimeManagerRangeToV1TimeRange( } export function mapTimeManagerComparisonRangeToV1TimeRange( - timeFilterManager: TimeFilterManager, + timeFilterManager: TimeFilterManager | undefined, timeRange: V1TimeRange | undefined, ): V1TimeRange | undefined { if ( !timeRange || + !timeFilterManager || !timeFilterManager.showComparison || !timeFilterManager.comparisonTimeRange ) { 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 873aca595144..0e72a240e989 100644 --- a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte +++ b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte @@ -11,7 +11,7 @@ dimensionSearchText, metricsExplorerStore, } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; - import ComparisonSelector from "@rilldata/web-common/features/dashboards/time-controls/ComparisonSelector.svelte"; + import ComparisonSelector from "@rilldata/web-common/features/dashboards/time-controls/comparison/ComparisonSelector.svelte"; import DelayedSpinner from "@rilldata/web-common/features/entity-management/DelayedSpinner.svelte"; import ExportMenu from "../../exports/ExportMenu.svelte"; import { featureFlags } from "../../feature-flags"; diff --git a/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte b/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte index b1357be37efd..0ce70c14ee87 100644 --- a/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte +++ b/web-common/src/features/dashboards/time-series/ScreenshotContainer.svelte @@ -189,7 +189,6 @@ tddChartType={tddChartType ?? TDDChart.DEFAULT} {metricsViewName} {timeDimension} - {ready} {comparisonDimension} {dimensionValues} {showTimeDimensionDetail} diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 047d9ded2942..0aac41e3e946 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -217,8 +217,6 @@ {#if hasTimeSeries} diff --git a/web-common/src/features/scheduled-reports/FiltersForm.svelte b/web-common/src/features/scheduled-reports/FiltersForm.svelte index d71f45bb06e3..e51ca90773d8 100644 --- a/web-common/src/features/scheduled-reports/FiltersForm.svelte +++ b/web-common/src/features/scheduled-reports/FiltersForm.svelte @@ -40,7 +40,8 @@ > Promise, - defaultUrlParamsGetter?: () => URLSearchParams | undefined, syncFromUrl = true, log = false, ) { @@ -22,20 +20,11 @@ export function syncStoreWithSource( $effect(() => { // Read all dependencies first so the subscription survives the guard. const currentUrl = page.url; - const defaultUrlParams = untrack(() => - defaultUrlParamsGetter ? defaultUrlParamsGetter() : undefined, - ); if (!store.specLoaded || lock) return; lock = true; const newUrlParams = new URLSearchParams(currentUrl.searchParams); - if (defaultUrlParams) { - defaultUrlParams.forEach((value, key) => { - if (newUrlParams.has(key)) return; - newUrlParams.set(key, value); - }); - } if (log) console.log("sync:fromUrl", newUrlParams.toString()); // No need to safeguard against unchanged url. @@ -52,10 +41,6 @@ export function syncStoreWithSource( const curStateParams = new URLSearchParams(); store.applyFilterToParams(curStateParams); - const defaultUrlParams = untrack(() => - defaultUrlParamsGetter ? defaultUrlParamsGetter() : undefined, - ); - if ( !store.dataLoaded || lock || @@ -64,13 +49,14 @@ export function syncStoreWithSource( return; lock = true; - const currentUrlParams = untrack(() => page.url.searchParams); + const currentUrlParams = untrack(() => + syncFromUrl + ? page.url.searchParams + : new URLSearchParams(prevStateParams), + ); prevStateParams = curStateParams; - let newUrlParams = new URLSearchParams(currentUrlParams); - if (defaultUrlParams) { - newUrlParams = cleanUrlParams(newUrlParams, defaultUrlParams); - } + const newUrlParams = new URLSearchParams(currentUrlParams); untrack(() => { store.applyFilterToParams(newUrlParams); }); diff --git a/web-common/tests/projects/AdBids/dashboards/AdBids_metrics_canvas.yaml b/web-common/tests/projects/AdBids/dashboards/AdBids_metrics_canvas.yaml index c5b199c7ed56..0538a3503c39 100644 --- a/web-common/tests/projects/AdBids/dashboards/AdBids_metrics_canvas.yaml +++ b/web-common/tests/projects/AdBids/dashboards/AdBids_metrics_canvas.yaml @@ -9,13 +9,13 @@ defaults: rows: - items: - kpi_grid: - metrics_view: AdBids_metrics - measures: - - total_records - - bid_price_sum comparison: - delta - percent_change + measures: + - total_records + - bid_price_sum + metrics_view: AdBids_metrics width: 12 height: 128px - items: From eae5e26389327662903d1a7dd3405498433b48da Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Mon, 21 Sep 2026 18:12:17 +0530 Subject: [PATCH 13/15] Fix admin tests --- .../alerts/metadata/AlertFilters.svelte | 15 ++-- web-admin/tests/bookmarks.spec.ts | 8 +-- .../src/features/alerts/AlertForm.svelte | 4 +- .../features/canvas/stores/canvas-entity.ts | 3 + .../dashboards/filters/Filters.svelte | 1 - .../StandaloneExpressionFiltersTest.svelte | 10 +++ .../state-managers/state-managers.ts | 1 + .../time-controls/ReadonlyTimeFilters.svelte | 3 +- .../time-controls/TimeFilterManager.svelte.ts | 69 +++++++++++-------- .../time-controls/test/time-filters-suite.ts | 2 - .../time-controls/time-range-mappers.ts | 31 ++++++++- .../dashboards/workspace/Dashboard.svelte | 1 - .../src/features/scheduled-reports/utils.ts | 1 + 13 files changed, 103 insertions(+), 46 deletions(-) diff --git a/web-admin/src/features/alerts/metadata/AlertFilters.svelte b/web-admin/src/features/alerts/metadata/AlertFilters.svelte index 1435f748bc7d..ef76fc493c8d 100644 --- a/web-admin/src/features/alerts/metadata/AlertFilters.svelte +++ b/web-admin/src/features/alerts/metadata/AlertFilters.svelte @@ -10,10 +10,13 @@ import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import ReadonlyExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ReadonlyExpressionFilters.svelte"; - import { onDestroy } from "svelte"; + import { onDestroy, untrack } from "svelte"; import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; - import { mapV1TimeRangeToRillTime } from "@rilldata/web-common/features/dashboards/time-controls/time-range-mappers.ts"; + import { + mapV1TimeRangeToComparisonTimeOption, + mapV1TimeRangeToRillTime, + } from "@rilldata/web-common/features/dashboards/time-controls/time-range-mappers.ts"; let { metricsViewName, @@ -54,7 +57,7 @@ false, ); $effect(() => { - if (!timeRange) return; + if (!timeRange || !timeFilterManager.specLoaded) return; const urlParams = new URLSearchParams(); urlParams.set( ExploreStateURLParams.TimeRange, @@ -64,11 +67,13 @@ if (comparisonTimeRange) { urlParams.set( ExploreStateURLParams.ComparisonTimeRange, - mapV1TimeRangeToRillTime(comparisonTimeRange), + mapV1TimeRangeToComparisonTimeOption(timeRange, comparisonTimeRange), ); } - timeFilterManager.setUrlParams(urlParams); + untrack(() => { + timeFilterManager.setUrlParams(urlParams); + }); }); // time range could be an empty object sometimes diff --git a/web-admin/tests/bookmarks.spec.ts b/web-admin/tests/bookmarks.spec.ts index fdd51702efa3..4c104413fb68 100644 --- a/web-admin/tests/bookmarks.spec.ts +++ b/web-admin/tests/bookmarks.spec.ts @@ -44,7 +44,7 @@ test.describe("Bookmarks", () => { // Assert the selected filters await expect(adminPage.getByLabel("Readonly Filter Chips")).toHaveText( - ` Last 6 hours App Site Name FuboTV +1 other `, + /Last 6 hours\s+vs Previous period\s+App Site Name FuboTV\s+\+1 other/, ); // Create a personal bookmark await enterBookmarkDetails( @@ -154,7 +154,7 @@ test.describe("Bookmarks", () => { // Assert the selected filters await expect(adminPage.getByLabel("Readonly Filter Chips")).toHaveText( - ` Last 6 hours App Site Name FuboTV +1 other `, + /Last 6 hours\s+vs Previous period\s+App Site Name FuboTV\s+\+1 other/, ); // Create a personal bookmark await enterBookmarkDetails( @@ -377,7 +377,7 @@ test.describe("Bookmarks", () => { // Assert the selected filters await expect(adminPage.getByLabel("Readonly Filter Chips")).toHaveText( - ` Last 6 hours Advertiser Name Instacart +1 other `, + /Last 6 hours\s+vs Previous period\s+Advertiser Name Instacart\s+\+1 other/, ); // Assert filters applied await expect( @@ -578,7 +578,7 @@ test.describe("Bookmarks", () => { }) => { // Add random params. Home bookmark shouldnt apply await adminPage.goto( - "/e2e/openrtb/canvas/bids_canvas_bookmarks?compare_tr=rill-PW", + "/e2e/openrtb/canvas/bids_canvas_bookmarks?tr=24h+as+of+latest%2Fh%2B1h&compare_tr=rill-PW", ); // Default time range is present await expect(adminPage.getByText("Last 24 Hours")).toBeVisible(); diff --git a/web-common/src/features/alerts/AlertForm.svelte b/web-common/src/features/alerts/AlertForm.svelte index f832fbfe70c9..c1dcdff64ae9 100644 --- a/web-common/src/features/alerts/AlertForm.svelte +++ b/web-common/src/features/alerts/AlertForm.svelte @@ -266,7 +266,9 @@ // if the user came to the delivery tab and name was not changed then auto generate it const name = generateAlertName( $form, - timeFilterManager.comparisonTimeRange, + timeFilterManager.showComparison + ? timeFilterManager.comparisonTimeRange + : undefined, metricsViewSpec, ); if (!name) return; diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index e79e29354d9c..331b46b02a26 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -180,6 +180,8 @@ export class CanvasEntity { this.dashboardProvider.metricsViewsProvider, this.dashboardProvider.yamlConfigProvider, false, + false, + false, ); this.processSpec(this.spec); @@ -555,6 +557,7 @@ export class CanvasEntity { // Third priority const defaultParamsString = get(this.defaultUrlParamsStore).toString(); + console.log("Default params string:", defaultParamsString); if (defaultParamsString) { await goto(`?${defaultParamsString}`, { diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 851e873238c5..48f4286c728e 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -28,7 +28,6 @@ syncExpressionFilters, // URL sync is managed by DashboardStateSync false, - true, ); syncStoreWithSource( diff --git a/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte b/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte index a4c6c8f395cc..e94d6d76e269 100644 --- a/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte +++ b/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte @@ -41,6 +41,16 @@ async (newUrlParams) => expressionFilterManager.setUrlParams(newUrlParams), false, ); + + // Call `setUrlParams` to load the data + let loaded = false; + $effect(() => { + if (loaded || !metricsViewsProvider.ready) { + return; + } + loaded = true; + expressionFilterManager.setUrlParams(new URLSearchParams()); + }); - {#if comparisonTimeRange && parsedComparisonTime} + {#if showComparison && comparisonTimeRange && parsedComparisonTime}
diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts index 4966e3db1666..d40d1ff89155 100644 --- a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts @@ -150,8 +150,10 @@ export class TimeFilterManager implements UrlParamsStore { private readonly runtimeClient: RuntimeClient, private readonly metricsViewsProvider: MetricsViewsProvider, private readonly yamlConfigProvider: YAMLConfigProvider, + // TODO: maybe this can be moved to yamlConfigProvider? public readonly allowCustomTimeRange: boolean, - private readonly addDefault: boolean = false, // TODO: maybe this can be moved to yamlConfigProvider? + private readonly addDefault: boolean = false, + private readonly saveGrain: boolean = true, ) { this.minDate = $derived.by(() => { const minDate = this.metricsViewsProvider.timeRangeSummary?.min @@ -284,9 +286,14 @@ export class TimeFilterManager implements UrlParamsStore { public setUrlParams(urlParams: URLSearchParams) { this.curParams = copySubsetParams(urlParams, TimeFilterParams); + this.dataLoaded = false; - const urlGrain = urlParams.get(ExploreStateURLParams.TimeGrain); - this.timeGrain = urlGrain ? DateTimeUnitToV1TimeGrain[urlGrain] : undefined; + if (this.saveGrain) { + const urlGrain = urlParams.get(ExploreStateURLParams.TimeGrain); + this.timeGrain = urlGrain + ? DateTimeUnitToV1TimeGrain[urlGrain] + : undefined; + } this.timeZone = urlParams.get(ExploreStateURLParams.TimeZone) ?? @@ -295,24 +302,6 @@ export class TimeFilterManager implements UrlParamsStore { this.timeDimension = urlParams.get(ExploreStateURLParams.TimeDimension) ?? undefined; - if (urlParams.has(ExploreStateURLParams.TimeRange)) { - void this.applyTimeRange(urlParams.get(ExploreStateURLParams.TimeRange)!); - } else { - let defaultTimeRange = this.yamlConfigProvider.defaultTimeRange; - if (!defaultTimeRange) { - defaultTimeRange = getDefaultTimeRange( - this.metricsViewsProvider.largestMinTimeGrain, - this.metricsViewsProvider.timeRangeSummary, - ); - } - if (defaultTimeRange && this.addDefault) { - void this.applyTimeRange(defaultTimeRange); - } else { - this.timeRange = undefined; - this.interval = undefined; - } - } - this.showComparison = urlParams.has( ExploreStateURLParams.ComparisonTimeRange, ); @@ -342,6 +331,27 @@ export class TimeFilterManager implements UrlParamsStore { // no-op } } + + // Apply time range last so that params are taken from url + if (urlParams.has(ExploreStateURLParams.TimeRange)) { + void this.applyTimeRange(urlParams.get(ExploreStateURLParams.TimeRange)!); + } else { + let defaultTimeRange = this.yamlConfigProvider.defaultTimeRange; + if (!defaultTimeRange) { + defaultTimeRange = getDefaultTimeRange( + this.metricsViewsProvider.largestMinTimeGrain, + this.metricsViewsProvider.timeRangeSummary, + ); + } + if (defaultTimeRange && this.addDefault) { + void this.applyTimeRange(defaultTimeRange); + } else { + this.timeRange = undefined; + this.fetchingTimeRange = ""; + this.interval = undefined; + this.dataLoaded = true; + } + } } public applyFilterToParams(urlParams: URLSearchParams) { @@ -351,13 +361,15 @@ export class TimeFilterManager implements UrlParamsStore { urlParams.delete(ExploreStateURLParams.TimeRange); } - const mappedGrain = this.timeGrain - ? V1TimeGrainToDateTimeUnit[this.timeGrain] - : undefined; - if (mappedGrain) { - urlParams.set(ExploreStateURLParams.TimeGrain, mappedGrain); - } else { - urlParams.delete(ExploreStateURLParams.TimeGrain); + if (this.saveGrain) { + const mappedGrain = this.timeGrain + ? V1TimeGrainToDateTimeUnit[this.timeGrain] + : undefined; + if (mappedGrain) { + urlParams.set(ExploreStateURLParams.TimeGrain, mappedGrain); + } else { + urlParams.delete(ExploreStateURLParams.TimeGrain); + } } if (this.timeZone !== DEFAULT_TIMEZONE) { @@ -522,6 +534,7 @@ export class TimeFilterManager implements UrlParamsStore { } public onToggleShowComparison() { this.showComparison = !this.showComparison; + console.log("Show comparison toggled:", this.showComparison); } public onScrubRange({ start, end, isScrubbing }: ScrubRange) { diff --git a/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts b/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts index 7acbff49d15f..2d03adfc5c06 100644 --- a/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts +++ b/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts @@ -257,7 +257,6 @@ export function testComparisonTimeRangeFilters(variant: TimeFiltersVariant) { }); await selectComparisonTimeRange("Previous week"); - console.log(variant.timeFilterManager.getTimeRange()); assertComparisonTimeRange({ expression: "rill-PW", start: "2024-03-18T00:00:00.000Z", @@ -271,7 +270,6 @@ export function testComparisonTimeRangeFilters(variant: TimeFiltersVariant) { assertUrlSearch(previousWeekComparisonUrl); await selectTimeRange(/Last 24 hours/); - console.log(variant.timeFilterManager.getTimeRange()); // Previous week selection was retained and calculated for 24 hrs instead assertComparisonTimeRange({ expression: "rill-PW", diff --git a/web-common/src/features/dashboards/time-controls/time-range-mappers.ts b/web-common/src/features/dashboards/time-controls/time-range-mappers.ts index 64c68ab1382d..8e8cd9e3a396 100644 --- a/web-common/src/features/dashboards/time-controls/time-range-mappers.ts +++ b/web-common/src/features/dashboards/time-controls/time-range-mappers.ts @@ -336,10 +336,13 @@ export function mapV1TimeRangeToRillTime(timeRange: V1TimeRange) { if (timeRange.start && timeRange.end) { return `${timeRange.start} to ${timeRange.end}`; } - return duration ?? timeRange.isoDuration; + return duration ?? timeRange.expression; } -export function mapV1TimeRangeToComparisonTimeOption(timeRange: V1TimeRange) { +export function mapV1TimeRangeToComparisonTimeOption( + primaryTimeRange: V1TimeRange, + timeRange: V1TimeRange, +) { let duration = timeRange.isoOffset; const fullRangeKey = `${timeRange.isoDuration ?? ""}_${timeRange.isoOffset ?? ""}_${timeRange.roundToGrain ?? ""}`; @@ -349,8 +352,30 @@ export function mapV1TimeRangeToComparisonTimeOption(timeRange: V1TimeRange) { if (timeRange.start && timeRange.end) { return `${timeRange.start} to ${timeRange.end}`; - } else if (timeRange.isoOffset === timeRange.isoDuration) { + } else if ( + timeRange.isoOffset && + timeRange.isoOffset === timeRange.isoDuration + ) { return TimeComparisonOption.CONTIGUOUS; + } else if (timeRange.expression) { + if ( + primaryTimeRange.expression && + timeRange.expression.startsWith(primaryTimeRange.expression) + ) { + const offset = timeRange.expression.replace( + primaryTimeRange.expression + " offset ", + "", + ); + for (const comparisonOption in TIME_COMPARISON) { + if (TIME_COMPARISON[comparisonOption].rillTimeOffset === offset) { + return comparisonOption; + } + } + + return timeRange.expression; + } + + return timeRange.expression; } return duration; diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 0aac41e3e946..01d0da2140be 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -74,7 +74,6 @@ $: ({ selectedTimeDimension } = $dashboardStore); const filterStore = expressionFilterManager.getExprStoreForMetricsView(metricsViewName); - $: dimensionOnlyFilter = $filterStore?.dimensionOnlyExpr; $: whereFilter = $filterStore?.expr; $: extraLeftPadding = !$navigationOpen; diff --git a/web-common/src/features/scheduled-reports/utils.ts b/web-common/src/features/scheduled-reports/utils.ts index 51d296d33be0..0d9c0cd74609 100644 --- a/web-common/src/features/scheduled-reports/utils.ts +++ b/web-common/src/features/scheduled-reports/utils.ts @@ -200,6 +200,7 @@ export function getFiltersAndTimeControlsFromAggregationRequest( if (aggregationRequest.comparisonTimeRange) { const selectedCompareTimeRange = mapV1TimeRangeToComparisonTimeOption( + timeRange, aggregationRequest.comparisonTimeRange, ); if (selectedCompareTimeRange) From 791cd117ab4ac452258d51797f1a4314c2d0349a Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 23 Sep 2026 19:20:54 +0530 Subject: [PATCH 14/15] Improve state tracking code stability --- .../alerts/metadata/AlertFilters.svelte | 2 +- .../bookmarks/BookmarksFormDialog.svelte | 12 +- .../public-urls/CreatePublicURLForm.svelte | 12 +- .../src/features/alerts/create-alert-utils.ts | 4 +- .../features/canvas/CanvasComponent.svelte | 3 - .../canvas/CanvasComponentSync.svelte | 20 ---- .../canvas/CanvasDashboardWrapper.svelte | 15 --- .../canvas/components/BaseCanvasComponent.ts | 6 +- .../filters/DimensionFiltersInput.svelte | 27 ++--- .../inspector/filters/TimeFiltersInput.svelte | 15 ++- .../features/canvas/stores/canvas-entity.ts | 12 +- .../filters/ExpressionFilterManager.svelte.ts | 95 +++++++--------- .../dashboards/filters/Filters.svelte | 34 ------ .../dashboards/filters/filter-events.ts | 1 + .../StandaloneExpressionFiltersTest.svelte | 7 -- .../loaders/DashboardStateSync.ts | 29 +++-- .../state-managers/selectors/measures.ts | 4 + .../state-managers/state-managers.ts | 13 +++ .../time-controls/TimeFilterManager.svelte.ts | 89 ++++++++++----- .../providers/MetricsViewsProvider.svelte.ts | 91 +++++++++++++-- .../scheduled-reports/FiltersForm.svelte | 10 -- .../src/features/scheduled-reports/utils.ts | 30 +++-- .../url-params-store-sync.svelte.ts | 96 ++-------------- .../url-search-params-store.svelte.ts | 104 ++++++++++++++++++ web-common/src/lib/url-utils.ts | 8 +- 25 files changed, 394 insertions(+), 345 deletions(-) delete mode 100644 web-common/src/features/canvas/CanvasComponentSync.svelte create mode 100644 web-common/src/lib/store-utils/url-search-params-store.svelte.ts diff --git a/web-admin/src/features/alerts/metadata/AlertFilters.svelte b/web-admin/src/features/alerts/metadata/AlertFilters.svelte index ef76fc493c8d..e28bca82f17e 100644 --- a/web-admin/src/features/alerts/metadata/AlertFilters.svelte +++ b/web-admin/src/features/alerts/metadata/AlertFilters.svelte @@ -57,7 +57,7 @@ false, ); $effect(() => { - if (!timeRange || !timeFilterManager.specLoaded) return; + if (!timeRange || !timeFilterManager.ready) return; const urlParams = new URLSearchParams(); urlParams.set( ExploreStateURLParams.TimeRange, diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index 446e0a6dff75..d289e5567391 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -35,7 +35,6 @@ ExploreDashboardConfigProvider, } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; import { onDestroy } from "svelte"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; let { @@ -78,14 +77,11 @@ dashboardConfigProvider.yamlConfigProvider, true, ); - // Always load from current state. This is the only route to overwrite bookmark state. + + // Always load from current url state. This is the only route to overwrite bookmark state. // A future PR will improve this by adding `Replace` action, in that case this should only have bookmark's state. - syncStoreWithSource(expressionFilterManager, async (newUrlParams) => - expressionFilterManager.setUrlParams(newUrlParams), - ); - syncStoreWithSource(timeFilterManager, async (newUrlParams) => - timeFilterManager.setUrlParams(newUrlParams), - ); + expressionFilterManager.setUrlParams(page.url.searchParams); + timeFilterManager.setUrlParams(page.url.searchParams); let timeFilterState = $state< | { diff --git a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte index 510e738c8c75..42ee4962c228 100644 --- a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte +++ b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte @@ -38,7 +38,6 @@ ExploreDashboardConfigProvider, } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; import { onDestroy } from "svelte"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; let { @@ -68,11 +67,6 @@ dashboardConfigProvider.metricsViewsProvider, dashboardConfigProvider.yamlConfigProvider, ); - // Always load from current state. This is the only route to overwrite bookmark state. - // A future PR will improve this by adding `Replace` action, in that case this should only have bookmark's state. - syncStoreWithSource(expressionFilterManager, async (newUrlParams) => - expressionFilterManager.setUrlParams(newUrlParams), - ); const timeFilterManager = new TimeFilterManager( runtimeClient, @@ -80,11 +74,11 @@ dashboardConfigProvider.yamlConfigProvider, true, ); + // Always load from current state. This is the only route to overwrite bookmark state. // A future PR will improve this by adding `Replace` action, in that case this should only have bookmark's state. - syncStoreWithSource(timeFilterManager, async (newUrlParams) => - timeFilterManager.setUrlParams(newUrlParams), - ); + expressionFilterManager.setUrlParams(page.url.searchParams); + timeFilterManager.setUrlParams(page.url.searchParams); const exprByMetricsView = $derived(expressionFilterManager.exprByMetricsView); const hasSomeFilter = $derived(Object.keys(exprByMetricsView).length > 0); diff --git a/web-common/src/features/alerts/create-alert-utils.ts b/web-common/src/features/alerts/create-alert-utils.ts index 8922fc8bf2db..e4378e5d8786 100644 --- a/web-common/src/features/alerts/create-alert-utils.ts +++ b/web-common/src/features/alerts/create-alert-utils.ts @@ -74,7 +74,7 @@ export function getNewAlertInitialFiltersFormValues( metricsViewProvider, yamlConfigProvider, ); - expressionFilterManager.setUrlParams(get(page).url.searchParams); + expressionFilterManager.storeSync.setUrlParams(get(page).url.searchParams); const timeFilterManager = new TimeFilterManager( client, @@ -82,7 +82,7 @@ export function getNewAlertInitialFiltersFormValues( yamlConfigProvider, false, ); - timeFilterManager.setUrlParams(get(page).url.searchParams); + timeFilterManager.storeSync.setUrlParams(get(page).url.searchParams); return { expressionFilterManager, diff --git a/web-common/src/features/canvas/CanvasComponent.svelte b/web-common/src/features/canvas/CanvasComponent.svelte index 454755ae95ba..755c1c8fc91f 100644 --- a/web-common/src/features/canvas/CanvasComponent.svelte +++ b/web-common/src/features/canvas/CanvasComponent.svelte @@ -8,7 +8,6 @@ - -
- import type { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent.ts"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; - - let { - component, - }: { - component: BaseCanvasComponent; - } = $props(); - - // TODO: move to CanvasComponent after migrating it to svelte5 - // svelte-ignore state_referenced_locally - syncStoreWithSource(component.expressionFilters, async () => - component.syncExpressionFilters(), - ); - // svelte-ignore state_referenced_locally - syncStoreWithSource(component.timeFilters, async () => - component.syncTimeFilters(), - ); - diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index 3eed7a7b0756..9c5639634073 100644 --- a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte +++ b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte @@ -9,8 +9,6 @@ import { getMissingRequiredFilters } from "@rilldata/web-common/features/dashboards/filters/utils.ts"; import MissingRequiredFiltersMessage from "@rilldata/web-common/features/dashboards/filters/MissingRequiredFiltersMessage.svelte"; import { type Snippet } from "svelte"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; - import { goto } from "$app/navigation"; const runtimeClient = useRuntimeClient(); let instanceId = $derived(runtimeClient.instanceId); @@ -48,19 +46,6 @@ dashboardProvider, }, } = $derived(getCanvasStore(canvasName, instanceId)); - // svelte-ignore state_referenced_locally - syncStoreWithSource(expressionFilterManager, (newUrlParams) => { - let newSearch = newUrlParams.toString(); - if (!newSearch) newSearch = "clear=true"; - return goto("?" + newSearch); - }); - - // svelte-ignore state_referenced_locally - syncStoreWithSource(timeFilterManager, (newUrlParams) => { - let newSearch = newUrlParams.toString(); - if (!newSearch) newSearch = "clear=true"; - return goto("?" + newSearch); - }); $effect(() => { dashboardProvider.yamlConfigProvider.setEditable(builder); diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index 629aeaa8b312..afe4aa2a2e74 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -131,6 +131,9 @@ export abstract class BaseCanvasComponent { this.metricsViewsProvider, this.yamlConfigProvider, this.parent.timeFilterManager.allowCustomTimeRange, + false, + true, + true, ); this.unsubscribeSpec = this.specStore.subscribe((spec) => { @@ -138,12 +141,10 @@ export abstract class BaseCanvasComponent { this.metricsViewName, (spec["dimension_filters"] ?? "") as string, ); - this.syncExpressionFilters(); this.localTimeFilters.setUrlParams( new URLSearchParams(spec?.["time_filters"] ?? ""), ); - this.syncTimeFilters(); }); } @@ -193,6 +194,7 @@ export abstract class BaseCanvasComponent { const urlParams = this.localTimeFilters.timeRange ? this.localTimeFilters.curParams : this.parent.timeFilterManager.curParams; + console.log("syncTimeFilters", urlParams); this.timeFilters.setUrlParams(urlParams); } diff --git a/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte b/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte index ef59b7f02926..805a99764288 100644 --- a/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte +++ b/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte @@ -7,7 +7,7 @@ getParamKeyForMv, } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; import VerticalExpressionFilters from "@rilldata/web-common/features/dashboards/filters/VerticalExpressionFilters.svelte"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; + import { onMount } from "svelte"; let { id, @@ -20,11 +20,15 @@ excludedDimensions: Record; updateLocalFilterString: (newFilterString: string) => void; } = $props(); - // svelte-ignore state_referenced_locally - syncStoreWithSource( - localExpressionFilters, - async (newUrlParams) => { - localExpressionFilters.setUrlParams(newUrlParams); + + let localFiltersEnabledOverride = $state(false); + + let localFiltersEnabled = $derived( + localExpressionFilters.hasSomeFilter || localFiltersEnabledOverride, + ); + + onMount(() => { + return localExpressionFilters.storeSync.on("change", (newUrlParams) => { updateLocalFilterString( newUrlParams.get( getParamKeyForMv( @@ -33,15 +37,8 @@ ), ) ?? "", ); - }, - false, - ); - - let localFiltersEnabledOverride = $state(false); - - let localFiltersEnabled = $derived( - localExpressionFilters.hasSomeFilter || localFiltersEnabledOverride, - ); + }); + });
diff --git a/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte b/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte index d1eb4f2efb35..977af810cd8a 100644 --- a/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte +++ b/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte @@ -9,7 +9,7 @@ import TimeFilters from "@rilldata/web-common/features/dashboards/time-controls/TimeFilters.svelte"; import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; + import { onMount } from "svelte"; let { id, @@ -38,13 +38,6 @@ let { canvasEntity: { timeFilterManager, dashboardProvider }, } = $derived(getCanvasStore(canvasName, instanceId)); - // svelte-ignore state_referenced_locally - syncStoreWithSource( - localTimeFilters, - async (newUrlParams) => - updateLocalTimeFilterString(newUrlParams.toString()), - false, - ); let { curParams } = $derived(localTimeFilters); @@ -55,6 +48,12 @@ let defaultTimeRange = $derived( dashboardProvider.yamlConfigProvider.defaultTimeRange, ); + + onMount(() => { + return localTimeFilters.storeSync.on("change", (newUrlParams) => { + updateLocalTimeFilterString(newUrlParams.toString()); + }); + });
diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index 331b46b02a26..56372d38b97d 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -52,6 +52,7 @@ import { flattenExpression } from "@rilldata/web-common/features/dashboards/stor import { CanvasDashboardConfigProvider } from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts"; import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; import { getComparisonTypeFromRangeString } from "@rilldata/web-common/features/dashboards/time-controls/time-range-utils.ts"; +import { page } from "$app/stores"; export const lastVisitedState = new Map(); @@ -174,6 +175,7 @@ export class CanvasEntity { if (source && source === get(this.activeComponent)) return; this.clearActiveComponent(); }); + this.expressionFilterManager.storeSync.syncToUrl("clear=true"); this.timeFilterManager = new TimeFilterManager( this.client, @@ -183,6 +185,7 @@ export class CanvasEntity { false, false, ); + this.timeFilterManager.storeSync.syncToUrl("clear=true"); this.processSpec(this.spec); } @@ -445,13 +448,8 @@ export class CanvasEntity { if (!isolated) { this.saveSnapshot(searchParams.toString()); } - // Only sync when metricsViewsProvider has loaded. Once loaded sync is handled by syncStoreWithSource - // TODO: find a good common method of sync between explore and canvas once time filters is also unified - if (this.dashboardProvider.metricsViewsProvider.ready) { - this.expressionFilterManager.setUrlParams(searchParams); - if (this.timeFilterManager.ready) - this.timeFilterManager.setUrlParams(searchParams); - } + this.expressionFilterManager.setUrlParams(searchParams); + this.timeFilterManager.setUrlParams(searchParams); this.applyTabsFromURL(searchParams); }; diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 51c1d37b8e11..1ef85a974f3d 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -24,6 +24,7 @@ import { mergeFilterParams } from "@rilldata/web-common/features/dashboards/filt import { getSortFilterManagers } from "@rilldata/web-common/features/dashboards/filters/get-sort-filter-managers.ts"; import { expandCompressedParams } from "@rilldata/web-common/features/dashboards/url-state/compression.ts"; import type { UrlParamsStore } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; +import { UrlParamsChangeTracker } from "@rilldata/web-common/lib/store-utils/url-search-params-store.svelte.ts"; export type ExpressionState = { expr: V1Expression | undefined; @@ -69,14 +70,31 @@ export class ExpressionFilterManager implements UrlParamsStore { // Temporary lock in explore. Once we move whereFilter out of explore, we can remove this. public updating = false; - public specLoaded: boolean; + public ready = $state(false); public dataLoaded = $state(false); + public paramKeys = new Set([ExploreStateURLParams.Filters]); + public readonly storeSync: UrlParamsChangeTracker; + public constructor( public readonly metricsViewsProvider: MetricsViewsProvider, public readonly yamlConfigProvider: YAMLConfigProvider, private readonly singleParamFormMv = false, ) { + this.storeSync = new UrlParamsChangeTracker(this); + metricsViewsProvider.on("update-metrics-views", (newMetricsViewsNames) => { + this.paramKeys = new Set([ + ExploreStateURLParams.Filters, + ...newMetricsViewsNames.map((mvName) => + getParamKeyForMv(mvName, singleParamFormMv), + ), + ]); + }); + metricsViewsProvider.on("specs-loaded", () => { + this.ready = true; + this.events.emit("ready"); + }); + this.topLevelJoiner = $state( JoinerFilterManager.parse( this.metricsViewsProvider, @@ -115,8 +133,6 @@ export class ExpressionFilterManager implements UrlParamsStore { this.hasSomeFilter = $derived( Object.keys(this.exprByMetricsView).length > 0, ); - - this.specLoaded = $derived(this.metricsViewsProvider.ready); } public clone() { @@ -133,34 +149,30 @@ export class ExpressionFilterManager implements UrlParamsStore { return cloned; } - public setUrlParams(searchParams: URLSearchParams) { + public normalizeParams(urlParams: URLSearchParams): URLSearchParams { let expandedUrlParams: URLSearchParams; try { - expandedUrlParams = expandCompressedParams(searchParams); + expandedUrlParams = expandCompressedParams(urlParams); } catch { // If we fail to decompress, do not throw here. - return; + return urlParams; } - // Use and save just the params set by this class. - const relevantUrlParams = new URLSearchParams(); - expandedUrlParams.forEach((value, key) => { - if ( - key === ExploreStateURLParams.Filters || - key.startsWith(ExploreStateURLParams.Filters + ".") - ) { - relevantUrlParams.append(key, value); - } - }); + const singularParam = expandedUrlParams.get(ExploreStateURLParams.Filters); + if (!singularParam || this.singleParamFormMv) return expandedUrlParams; - // Do not update managers if params didnt change. - if ( - this.curParams && - this.curParams.toString() === relevantUrlParams.toString() - ) - return; + const newUrlParams = new URLSearchParams(); + this.metricsViewsProvider.metricsViewNames.forEach((mvName) => + newUrlParams.set( + getParamKeyForMv(mvName, this.singleParamFormMv), + singularParam, + ), + ); + return newUrlParams; + } - const { expr, inList, advanced } = mergeFilterParams(relevantUrlParams); + public setUrlParams(searchParams: URLSearchParams) { + const { expr, inList, advanced } = mergeFilterParams(searchParams); this.temporaryFilterName = undefined; this.topLevelJoiner = JoinerFilterManager.parse( @@ -171,20 +183,14 @@ export class ExpressionFilterManager implements UrlParamsStore { this.events, ) as JoinerFilterManager; this.isComplexFilter = advanced; - this.dataLoaded = true; - - this.curParams = normalizeUrlParams( - relevantUrlParams, - this.metricsViewsProvider.metricsViewNames, - this.singleParamFormMv, - ); } public setParamForMetricsView(mvName: string, param: string) { const paramKey = getParamKeyForMv(mvName, this.singleParamFormMv); const newParams = new URLSearchParams(this.curParams); newParams.set(paramKey, param); - this.setUrlParams(newParams); + // Thread through the sync code to ensure only changes update the internal state. + this.storeSync.setUrlParams(newParams); } public setExprForMetricsView( @@ -244,10 +250,9 @@ export class ExpressionFilterManager implements UrlParamsStore { } } - // TODO: add return type based on callback type? - public dimensionFilterAction( + public dimensionFilterAction( name: string, - callback: (dimensionFilterManager: DimensionFilterManager) => any, + callback: (dimensionFilterManager: DimensionFilterManager) => Ret, // Anything other than the filter bar reaches the managers through here, // so this is where a caller names itself as the cause of the change. source?: FilterChangeSource, @@ -271,9 +276,9 @@ export class ExpressionFilterManager implements UrlParamsStore { return ret; } - public measureFilterAction( + public measureFilterAction( name: string, - callback: (measureFilterManager: MeasureFilterManager) => any, + callback: (measureFilterManager: MeasureFilterManager) => Ret, source?: FilterChangeSource, ) { const measureFilterManager = @@ -350,24 +355,6 @@ export class ExpressionFilterManager implements UrlParamsStore { } } -function normalizeUrlParams( - urlParams: URLSearchParams, - metricsViews: string[], - singleParamFormMv: boolean, -) { - const singularParam = urlParams.get(ExploreStateURLParams.Filters); - if (!singularParam || singleParamFormMv) return urlParams; - - const newUrlParams = new URLSearchParams(); - metricsViews.forEach((mvName) => - newUrlParams.set( - getParamKeyForMv(mvName, singleParamFormMv), - singularParam, - ), - ); - return newUrlParams; -} - export function getParamKeyForMv(mvName: string, singleParamFormMv: boolean) { return singleParamFormMv ? ExploreStateURLParams.Filters diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index 48f4286c728e..9bf9466c2d7d 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -2,17 +2,14 @@ import { DashboardStateSync } from "@rilldata/web-common/features/dashboards/state-managers/loaders/DashboardStateSync"; import { isUrlTooLong } from "@rilldata/web-common/features/dashboards/url-state/url-length-limits"; import { getStateManagers } from "../state-managers/state-managers"; - import { metricsExplorerStore } from "../stores/dashboard-stores"; import ExpressionFilters from "@rilldata/web-common/features/dashboards/filters/ExpressionFilters.svelte"; import { createAndExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils.ts"; import { untrack } from "svelte"; import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; - import { syncStoreWithSource } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; import TimeFilters from "@rilldata/web-common/features/dashboards/time-controls/TimeFilters.svelte"; const StateManagers = getStateManagers(); const { - exploreName, dashboardStore, dashboardConfigProvider, expressionFilterManager, @@ -23,20 +20,6 @@ dashboardConfigProvider, ); - syncStoreWithSource( - expressionFilterManager, - syncExpressionFilters, - // URL sync is managed by DashboardStateSync - false, - ); - - syncStoreWithSource( - timeFilterManager, - syncTimeFilters, - // URL sync is managed by DashboardStateSync - false, - ); - let { hasTimeSeries, timeStart, @@ -75,23 +58,6 @@ return isUrlTooLong(url); }); } - - function syncExpressionFilters() { - if (!expressionFilterManager.updating) { - metricsExplorerStore.syncExpressionFilter( - $exploreName, - expressionFilterManager, - ); - } - return Promise.resolve(); - } - - function syncTimeFilters() { - if (!timeFilterManager.updating) { - metricsExplorerStore.syncTimeFilters($exploreName, timeFilterManager); - } - return Promise.resolve(); - }
diff --git a/web-common/src/features/dashboards/filters/filter-events.ts b/web-common/src/features/dashboards/filters/filter-events.ts index 0501aefb7e35..a710f0326645 100644 --- a/web-common/src/features/dashboards/filters/filter-events.ts +++ b/web-common/src/features/dashboards/filters/filter-events.ts @@ -13,6 +13,7 @@ export type FilterEvents = { // Emitted when a filter is manually removed from a manager. // This is useful when removal doesn't change expr and the manager has to be manually removed. "filter-removed": { name: string; wasEmpty: boolean }; + ready: void; }; /** diff --git a/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte b/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte index e94d6d76e269..0de4a0d1f9ef 100644 --- a/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte +++ b/web-common/src/features/dashboards/filters/test/StandaloneExpressionFiltersTest.svelte @@ -1,7 +1,6 @@ diff --git a/web-common/src/features/scheduled-reports/utils.ts b/web-common/src/features/scheduled-reports/utils.ts index 0d9c0cd74609..d276b3ade7d6 100644 --- a/web-common/src/features/scheduled-reports/utils.ts +++ b/web-common/src/features/scheduled-reports/utils.ts @@ -24,6 +24,7 @@ import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboard import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; +import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; export enum ReportRunAs { Recipient = "recipient", @@ -194,22 +195,35 @@ export function getFiltersAndTimeControlsFromAggregationRequest( yamlConfigProvider, false, ); - if (timeRange?.roundToGrain) - timeFilterManager.onSelectGrain(timeRange.roundToGrain); - if (timeRange?.timeZone) timeFilterManager.onSelectZone(timeRange.timeZone); + const timeFilterParams = new URLSearchParams(); + if (timeRange?.roundToGrain) { + timeFilterParams.set( + ExploreStateURLParams.TimeGrain, + timeRange.roundToGrain, + ); + } + if (timeRange?.timeZone) { + timeFilterParams.set(ExploreStateURLParams.TimeZone, timeRange.timeZone); + } if (aggregationRequest.comparisonTimeRange) { const selectedCompareTimeRange = mapV1TimeRangeToComparisonTimeOption( timeRange, aggregationRequest.comparisonTimeRange, ); - if (selectedCompareTimeRange) - timeFilterManager.onSelectComparisonRange(selectedCompareTimeRange); + if (selectedCompareTimeRange) { + timeFilterParams.set( + ExploreStateURLParams.ComparisonTimeRange, + selectedCompareTimeRange, + ); + } } - const selectedTimeRange = mapV1TimeRangeToRillTime(timeRange); - if (selectedTimeRange) - void timeFilterManager.onSelectRange(selectedTimeRange); + if (selectedTimeRange) { + timeFilterParams.set(ExploreStateURLParams.TimeRange, selectedTimeRange); + } + + timeFilterManager.storeSync.setUrlParams(timeFilterParams); return { expressionFilterManager, diff --git a/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts b/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts index 8cc7c5462f46..e2792f1a1ae1 100644 --- a/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts +++ b/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts @@ -1,91 +1,19 @@ -import { page } from "$app/state"; -import { untrack } from "svelte"; +import type { EventEmitter } from "@rilldata/web-common/lib/event-emitter.ts"; + +type UrlParamsStoreEvents = { + ready: void; +}; export interface UrlParamsStore { setUrlParams(urlParams: URLSearchParams): void; applyFilterToParams(urlParams: URLSearchParams): void; - specLoaded: boolean; - dataLoaded: boolean; -} - -export function syncStoreWithSource( - store: UrlParamsStore, - sync: (newUrlParams: URLSearchParams) => Promise, - syncFromUrl = true, - log = false, -) { - let lock = false; - - if (syncFromUrl) { - $effect(() => { - // Read all dependencies first so the subscription survives the guard. - const currentUrl = page.url; - - if (!store.specLoaded || lock) return; - lock = true; - - const newUrlParams = new URLSearchParams(currentUrl.searchParams); - - if (log) console.log("sync:fromUrl", newUrlParams.toString()); - // No need to safeguard against unchanged url. - // It should already happen in setUrlParams since it will have other callers. - untrack(() => store.setUrlParams(newUrlParams)); - - lock = false; - }); - } - - let prevStateParams = new URLSearchParams(); - $effect(() => { - // Read all dependencies first so the subscription survives the guard. - const curStateParams = new URLSearchParams(); - store.applyFilterToParams(curStateParams); - - if ( - !store.dataLoaded || - lock || - curStateParams.toString() === prevStateParams.toString() - ) - return; - lock = true; - - const currentUrlParams = untrack(() => - syncFromUrl - ? page.url.searchParams - : new URLSearchParams(prevStateParams), - ); - prevStateParams = curStateParams; - - const newUrlParams = new URLSearchParams(currentUrlParams); - untrack(() => { - store.applyFilterToParams(newUrlParams); - }); + ready: boolean; - if (log) { - console.log( - "sync:toUrl", - newUrlParams.toString() === currentUrlParams.toString(), - newUrlParams.toString(), - ); - } - if (newUrlParams.toString() === currentUrlParams.toString()) { - lock = false; - return; - } - try { - // Do not react to `sync` method changes - const syncPromise = untrack(() => sync(newUrlParams)); - if (!syncPromise.then) { - lock = false; - return; - } + on: EventEmitter["on"]; - void syncPromise.then( - () => (lock = false), - () => (lock = false), - ); - } catch { - lock = false; - } - }); + /** + * Keys set by this class. + */ + paramKeys: Set; + normalizeParams(urlParams: URLSearchParams): URLSearchParams; } diff --git a/web-common/src/lib/store-utils/url-search-params-store.svelte.ts b/web-common/src/lib/store-utils/url-search-params-store.svelte.ts new file mode 100644 index 000000000000..f9af9643f8d0 --- /dev/null +++ b/web-common/src/lib/store-utils/url-search-params-store.svelte.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from "@rilldata/web-common/lib/event-emitter.ts"; +import { copySubsetParams } from "@rilldata/web-common/lib/url-utils.ts"; +import type { UrlParamsStore } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; +import { page } from "$app/state"; +import { goto } from "$app/navigation"; + +type UrlParamsChangeTrackerEvents = { + /** + * Fired when the underlying class's state changes. + * Does not fire when `setUrlParams` is called to avoid loop. + */ + change: URLSearchParams; +}; + +/** + * A class that tracks URL search params that splits into multiple stores but backed by a single class. + * + * External sources like navigation should call `setUrlParams` and listen to `change` to sync. + * The internal store class that expands the search params into multiple stores should call `stateChanged` and listens to `set` to sync. + */ +export class UrlParamsChangeTracker { + /** + * Source of truth for the underlying class's state. + */ + public searchParams = $state(new URLSearchParams()); + + private pendingParams: URLSearchParams | undefined = undefined; + + private events = new EventEmitter(); + public readonly on = this.events.on.bind( + this.events, + ) as typeof this.events.on; + + public constructor(private readonly store: UrlParamsStore) { + this.store.on("ready", () => this.replayPendingParams()); + } + + /** + * Called by external sources like navigation to sync the search params. + * Calls `setUrlParams` on the store class. + */ + public setUrlParams(urlParams: URLSearchParams) { + if (!this.store.ready) { + this.pendingParams = urlParams; + return; + } + + const relevantParams = copySubsetParams(urlParams, this.store.paramKeys); + if (this.searchParams.toString() === relevantParams.toString()) return; + + this.searchParams = this.store.normalizeParams(relevantParams); + this.store.setUrlParams(relevantParams); + } + + /** + * Called by internal store class when its state is directly changed like a user action. + * Calls `applyFilterToParams` to get the actual url params in a new microtask so that changes are propagated. + * Fires `change` event so that external sources like navigation can sync. + */ + public stateChanged() { + queueMicrotask(() => this.maybeNotifyStateChange()); + } + + /** + * Syncs the store's state to the URL by listening to 'change' event. + * Returns the unsub method from the event listener. It is the callers' responsibility to call it. + * @param emptySearchOverride The search override to use if the store's state is empty. + */ + public syncToUrl(emptySearchOverride = "") { + return this.on("change", (newUrlParams) => { + const urlParamsToApply = new URLSearchParams(page.url.searchParams); + this.store.paramKeys.forEach((key) => { + if (newUrlParams.has(key)) { + urlParamsToApply.set(key, newUrlParams.get(key) ?? ""); + } else { + urlParamsToApply.delete(key); + } + }); + + let searchToApply = urlParamsToApply.toString(); + if (!searchToApply) searchToApply = emptySearchOverride; + void goto("?" + searchToApply); + }); + } + + private replayPendingParams() { + if (!this.pendingParams) return; + const pendingParams = this.pendingParams; + // Unset before calling setUrlParams. + // This will ensure that if params are still supposed to be pending, they are not cleared. + this.pendingParams = undefined; + + this.setUrlParams(pendingParams); + } + + private maybeNotifyStateChange() { + const urlParams = new URLSearchParams(); + this.store.applyFilterToParams(urlParams); + + if (this.searchParams.toString() === urlParams.toString()) return; + this.searchParams = urlParams; + this.events.emit("change", this.searchParams); + } +} diff --git a/web-common/src/lib/url-utils.ts b/web-common/src/lib/url-utils.ts index b1a3dd2d4559..e1a4a0c87b3e 100644 --- a/web-common/src/lib/url-utils.ts +++ b/web-common/src/lib/url-utils.ts @@ -44,11 +44,11 @@ export function unorderedParamsAreEqual( return true; } -export function copySubsetParams(src: URLSearchParams, params: Set) { +export function copySubsetParams(src: URLSearchParams, keys: Set) { const newParams = new URLSearchParams(); - for (const param of params) { - if (!src.has(param)) continue; - newParams.set(param, src.get(param)!); + for (const key of keys) { + if (!src.has(key)) continue; + newParams.set(key, src.get(key)!); } return newParams; } From 91e0497c376e80131bb02b72a140581822b28dee Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Fri, 25 Sep 2026 16:43:06 +0530 Subject: [PATCH 15/15] Support 'inherit' time option --- .../alerts/metadata/AlertFilters.svelte | 1 - .../bookmarks/BookmarksFormDialog.svelte | 1 - .../public-urls/CreatePublicURLForm.svelte | 1 - .../src/features/alerts/create-alert-utils.ts | 1 - .../features/canvas/LocalFiltersHeader.svelte | 64 - .../canvas/components/BaseCanvasComponent.ts | 77 +- .../inspector/DefaultFilterDisplay.svelte | 1 - .../filters/DimensionFiltersInput.svelte | 23 +- .../inspector/filters/FiltersMapper.svelte | 3 - .../inspector/filters/TimeFiltersInput.svelte | 88 +- .../features/canvas/stores/canvas-entity.ts | 7 +- .../core/messages/chart/ChartBlock.svelte | 1 - .../filters/ExpressionFilterManager.spec.ts | 86 +- .../filters/ExpressionFilterManager.svelte.ts | 40 +- .../filters/JoinerFilterManager.svelte.ts | 12 + .../DimensionFilterManager.svelte.ts | 5 - .../dashboards/filters/filter-events.ts | 3 - .../MeasureFilterManager.svelte.ts | 5 - .../filters/test/expression-filters-suite.ts | 2 + .../loaders/DashboardStateSync.ts | 18 +- .../state-managers/state-managers.ts | 12 +- .../dashboards/stores/dashboard-stores.ts | 110 +- .../time-controls/TimeFilterManager.spec.ts | 1106 +++++++++++++++++ .../time-controls/TimeFilterManager.svelte.ts | 195 +-- .../ComparisonTimeRangePicker.svelte | 30 +- .../time-controls/new-time-controls.ts | 5 + .../super-pill/TimeRangePicker.svelte | 21 +- .../InheritTimeRangeOption.svelte | 24 + .../test/canvas-time-filters-variant.ts | 2 +- .../test/explore-time-filters-variant.ts | 4 +- .../time-controls/time-filters-config.ts | 6 +- .../features/dashboards/time-series/utils.ts | 4 +- .../providers/MetricsViewsProvider.svelte.ts | 5 +- .../src/features/scheduled-reports/utils.ts | 1 - .../url-search-params-store.svelte.ts | 47 +- 35 files changed, 1568 insertions(+), 443 deletions(-) create mode 100644 web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts create mode 100644 web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/InheritTimeRangeOption.svelte diff --git a/web-admin/src/features/alerts/metadata/AlertFilters.svelte b/web-admin/src/features/alerts/metadata/AlertFilters.svelte index e28bca82f17e..d1af01c202f1 100644 --- a/web-admin/src/features/alerts/metadata/AlertFilters.svelte +++ b/web-admin/src/features/alerts/metadata/AlertFilters.svelte @@ -54,7 +54,6 @@ runtimeClient, metricsViewProvider, yamlConfigProvider, - false, ); $effect(() => { if (!timeRange || !timeFilterManager.ready) return; diff --git a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte index d289e5567391..c45fc27966be 100644 --- a/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte +++ b/web-admin/src/features/bookmarks/BookmarksFormDialog.svelte @@ -75,7 +75,6 @@ runtimeClient, dashboardConfigProvider.metricsViewsProvider, dashboardConfigProvider.yamlConfigProvider, - true, ); // Always load from current url state. This is the only route to overwrite bookmark state. diff --git a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte index 42ee4962c228..683a66a4b18d 100644 --- a/web-admin/src/features/public-urls/CreatePublicURLForm.svelte +++ b/web-admin/src/features/public-urls/CreatePublicURLForm.svelte @@ -72,7 +72,6 @@ runtimeClient, dashboardConfigProvider.metricsViewsProvider, dashboardConfigProvider.yamlConfigProvider, - true, ); // Always load from current state. This is the only route to overwrite bookmark state. diff --git a/web-common/src/features/alerts/create-alert-utils.ts b/web-common/src/features/alerts/create-alert-utils.ts index e4378e5d8786..57264fb1712f 100644 --- a/web-common/src/features/alerts/create-alert-utils.ts +++ b/web-common/src/features/alerts/create-alert-utils.ts @@ -80,7 +80,6 @@ export function getNewAlertInitialFiltersFormValues( client, metricsViewProvider, yamlConfigProvider, - false, ); timeFilterManager.storeSync.setUrlParams(get(page).url.searchParams); diff --git a/web-common/src/features/canvas/LocalFiltersHeader.svelte b/web-common/src/features/canvas/LocalFiltersHeader.svelte index b90a682075e5..ccd5729b23b3 100644 --- a/web-common/src/features/canvas/LocalFiltersHeader.svelte +++ b/web-common/src/features/canvas/LocalFiltersHeader.svelte @@ -1,68 +1,11 @@ {#if "metrics_view" in $specStore} @@ -73,14 +16,7 @@ >>>>>> main hasBoldTimeRange={false} chipLayout="scroll" /> diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index 629c078ef531..6c8c8be363b8 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -18,15 +18,13 @@ import { derived, get, writable, type Writable } from "svelte/store"; import { mergeFilters } from "../../dashboards/pivot/pivot-merge-filters"; import type { CanvasEntity, ComponentPath } from "../stores/canvas-entity"; import { ExpressionFilterManager } from "@rilldata/web-common/features/dashboards/filters/ExpressionFilterManager.svelte.ts"; -import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; +import { + DEFAULT_INHERIT_URL_PARAMS, + TimeFilterManager, +} from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; import { dedupe } from "@rilldata/web-common/lib/arrayUtils.ts"; import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; -import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; -import { - normalizeTimeFilters, - TIME_FILTER_INHERIT, -} from "@rilldata/web-common/features/canvas/components/time-filters.ts"; export abstract class BaseCanvasComponent { id: string; @@ -62,6 +60,7 @@ export abstract class BaseCanvasComponent { // a component replaced in CanvasEntity.processRows keeps reacting to spec // emissions and mutates the shared filter/time state of a deleted widget. private unsubscribeSpec: Unsubscriber; + private unsubExpressionSync: Unsubscriber; abstract type: CanvasComponentType; // Component responsible for DOM rendering. @@ -116,14 +115,19 @@ export abstract class BaseCanvasComponent { this.localExpressionFilters = new ExpressionFilterManager( this.metricsViewsProvider, this.yamlConfigProvider, + false, ); this.localTimeFilters = new TimeFilterManager( this.parent.client, this.metricsViewsProvider, this.yamlConfigProvider, - this.parent.timeFilterManager.allowCustomTimeRange, - false, + { + ...this.parent.timeFilterManager.config, + skipTimeGrain: true, + log: true, + }, + this.parent.timeFilterManager, ); this.expressionFilters = new ExpressionFilterManager( @@ -131,32 +135,33 @@ export abstract class BaseCanvasComponent { this.yamlConfigProvider, ); - this.timeFilters = new TimeFilterManager( - this.parent.client, - this.metricsViewsProvider, - this.yamlConfigProvider, - this.parent.timeFilterManager.allowCustomTimeRange, - false, - true, - true, - ); + this.timeFilters = this.localTimeFilters; this.unsubscribeSpec = this.specStore.subscribe((spec) => { this.localExpressionFilters.setParamForMetricsView( this.metricsViewName, (spec["dimension_filters"] ?? "") as string, ); + this.syncExpressionFilters(); this.localTimeFilters.setUrlParams( - new URLSearchParams(spec?.["time_filters"] ?? ""), + spec?.["time_filters"] + ? new URLSearchParams(spec["time_filters"]) + : new URLSearchParams(DEFAULT_INHERIT_URL_PARAMS), ); }); + + this.unsubExpressionSync = this.parent.expressionFilterManager.storeSync.on( + "change", + () => this.syncExpressionFilters(), + ); } destroy() { this.unsubscribeSpec?.(); - this.localExpressionFilters.metricsViewsProvider.cleanup(); - this.localExpressionFilters.yamlConfigProvider.cleanup?.(); + this.unsubExpressionSync?.(); + this.metricsViewsProvider.cleanup(); + this.yamlConfigProvider.cleanup?.(); } update(resource: V1Resource, path: ComponentPath) { @@ -195,14 +200,6 @@ export abstract class BaseCanvasComponent { ); } - public syncTimeFilters() { - const urlParams = this.localTimeFilters.timeRange - ? this.localTimeFilters.curParams - : this.parent.timeFilterManager.curParams; - console.log("syncTimeFilters", urlParams); - this.timeFilters.setUrlParams(urlParams); - } - private updateYAML(newSpec: T) { if (!this.parent.fileArtifact) return; const parseDocumentStore = this.parent.parsedContent; @@ -251,28 +248,4 @@ export abstract class BaseCanvasComponent { } this.specStore.set(newSpec); } - - // Sets how this component compares against a previous period: - // "inherit" follows the canvas, "none" turns comparison off, - // anything else is a comparison range such as rill-PW or a custom start,end pair. - setComparisonRange(value: string) { - const searchParams = new URLSearchParams( - (get(this.specStore)?.["time_filters"] ?? "") as string, - ); - - if (value === "none") { - searchParams.delete(ExploreStateURLParams.ComparisonTimeRange); - // Off is spelled by a `tr` without `compare_tr`. - if (!searchParams.has(ExploreStateURLParams.TimeRange)) { - searchParams.set(ExploreStateURLParams.TimeRange, TIME_FILTER_INHERIT); - } - } else { - searchParams.set(ExploreStateURLParams.ComparisonTimeRange, value); - } - - this.updateProperty( - "time_filters" as AllKeys, - normalizeTimeFilters(searchParams) as T[AllKeys], - ); - } } diff --git a/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte b/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte index 5cd9d2f5ab5d..5a9d3f78339e 100644 --- a/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte +++ b/web-common/src/features/canvas/inspector/DefaultFilterDisplay.svelte @@ -27,7 +27,6 @@ runtimeClient, dashboardProvider.metricsViewsProvider, dashboardProvider.yamlConfigProvider, - false, ), ); diff --git a/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte b/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte index 805a99764288..0c5cde5986b7 100644 --- a/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte +++ b/web-common/src/features/canvas/inspector/filters/DimensionFiltersInput.svelte @@ -28,16 +28,19 @@ ); onMount(() => { - return localExpressionFilters.storeSync.on("change", (newUrlParams) => { - updateLocalFilterString( - newUrlParams.get( - getParamKeyForMv( - localExpressionFilters.metricsViewsProvider.metricsViewNames[0], - false, - ), - ) ?? "", - ); - }); + return localExpressionFilters.storeSync.on( + "internal-change", + (newUrlParams) => { + updateLocalFilterString( + newUrlParams.get( + getParamKeyForMv( + localExpressionFilters.metricsViewsProvider.metricsViewNames[0], + false, + ), + ) ?? "", + ); + }, + ); }); diff --git a/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte b/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte index 124f808eb6f5..67cfe1147da4 100644 --- a/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte +++ b/web-common/src/features/canvas/inspector/filters/FiltersMapper.svelte @@ -22,7 +22,6 @@ localTimeFilters, metricsViewsProvider, yamlConfigProvider, - parent: { name: canvasName }, } = $derived(component); let localParamValues = $derived($specStore); @@ -75,13 +74,11 @@ {#if config.type === "time_filters"} {#if hasTimeSeries} { component.updateProperty("time_filters", newString); }} diff --git a/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte b/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte index 977af810cd8a..cffc0d481426 100644 --- a/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte +++ b/web-common/src/features/canvas/inspector/filters/TimeFiltersInput.svelte @@ -1,10 +1,7 @@ @@ -61,44 +51,28 @@ - { - if (localFiltersEnabled) { - localTimeFilters.setUrlParams(new URLSearchParams()); - } else { - void localTimeFilters.onSelectRange( - globalRange ?? defaultTimeRange ?? ALL_TIME_RANGE_ALIAS, - ); - } - }} - small />
- {#if localFiltersEnabled} - {m.canvas_overriding_inherited_time_filters()} + {#if hasLocalTimeRange} + {m.canvas_time_range_override_hint()} {:else} - {m.canvas_override_inherited_time_filters_hint()} + {m.canvas_time_range_inherit_hint()} {/if}
- {#if localFiltersEnabled} - - {/if} +
diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index 56372d38b97d..209aa9ed2aca 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -181,9 +181,6 @@ export class CanvasEntity { this.client, this.dashboardProvider.metricsViewsProvider, this.dashboardProvider.yamlConfigProvider, - false, - false, - false, ); this.timeFilterManager.storeSync.syncToUrl("clear=true"); @@ -448,8 +445,8 @@ export class CanvasEntity { if (!isolated) { this.saveSnapshot(searchParams.toString()); } - this.expressionFilterManager.setUrlParams(searchParams); - this.timeFilterManager.setUrlParams(searchParams); + this.expressionFilterManager.storeSync.setUrlParams(searchParams); + this.timeFilterManager.storeSync.setUrlParams(searchParams); this.applyTabsFromURL(searchParams); }; diff --git a/web-common/src/features/chat/core/messages/chart/ChartBlock.svelte b/web-common/src/features/chat/core/messages/chart/ChartBlock.svelte index aaa8396094c3..0bc2d26669c1 100644 --- a/web-common/src/features/chat/core/messages/chart/ChartBlock.svelte +++ b/web-common/src/features/chat/core/messages/chart/ChartBlock.svelte @@ -59,7 +59,6 @@ runtimeClient, metricsViewsProvider, yamlConfigProvider, - true, ); onDestroy(() => { metricsViewsProvider.cleanup(); diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts index 1a57acc7b2ab..2f569af9f419 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.spec.ts @@ -48,6 +48,7 @@ import { it, vi, } from "vitest"; +import { asyncWait } from "@rilldata/web-common/lib/waitUtils.ts"; // --------------------------------------------------------------------------- // Test metrics views @@ -158,14 +159,15 @@ function exprOf( // --------------------------------------------------------------------------- describe("setUrlParams", () => { - it("builds a dimension chip from a per metrics view param", () => { + it("builds a dimension chip from a per metrics view param", async () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), ); + await asyncWait(100); expect(names(filterManager.sortedFilterManagers.dimensions)).toEqual([ AD_BIDS_PUBLISHER_DIMENSION, @@ -183,7 +185,7 @@ describe("setUrlParams", () => { it("builds a measure chip from a having param", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10)`, }), @@ -228,7 +230,7 @@ describe("setUrlParams", () => { it(`builds a chip for the base measure from a ${suffix} filter`, () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE}${suffix} gt ${paramValue})`, }), @@ -264,7 +266,7 @@ describe("setUrlParams", () => { it(`writes a ${suffix} filter back to the param unchanged`, () => { const filterManager = createFilterManager(); const param = `${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE}${suffix} GT ${paramValue})`; - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: param }), ); @@ -281,7 +283,7 @@ describe("setUrlParams", () => { it("reads an in-list filter back as in-list mode", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN LIST ('Google','Facebook')`, }), @@ -298,7 +300,7 @@ describe("setUrlParams", () => { it("applies the singular param to every metrics view and shares one manager", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), ); @@ -326,7 +328,7 @@ describe("setUrlParams", () => { }).toString(), ), }); - filterManager.setUrlParams(compressed); + filterManager.storeSync.setUrlParams(compressed); expect(names(filterManager.sortedFilterManagers.dimensions)).toEqual([ AD_BIDS_PUBLISHER_DIMENSION, @@ -346,7 +348,7 @@ describe("setUrlParams", () => { `${ExploreStateURLParams.Filters}.${AD_BIDS_MIRROR_METRICS_NAME}`, `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, ); - filterManager.setUrlParams(searchParams); + filterManager.storeSync.setUrlParams(searchParams); // The mirror reads its own param rather than the singular one. The two are then merged into // the one filter the chips edit, and each metrics view gets the part of it that it defines: @@ -369,7 +371,7 @@ describe("setUrlParams", () => { const filterManager = createFilterManager(); // `country` belongs to the mirror only, so AdBids cannot filter on it. - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, }), @@ -390,7 +392,7 @@ describe("setUrlParams", () => { // `domain` is AdBids only and `country` is mirror only, so the merged filter splits back into // one condition per metrics view. - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, @@ -419,7 +421,7 @@ describe("setUrlParams", () => { const filterManager = createFilterManager(); // `bid_price` on `domain` is AdBids only, `publisher_count` on `country` is mirror only. - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE} gt 10)`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} having (${AD_BIDS_PUBLISHER_COUNT_MEASURE} lt 5)`, @@ -445,7 +447,7 @@ describe("setUrlParams", () => { it("flags an OR filter as complex", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') OR ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, }), @@ -457,7 +459,7 @@ describe("setUrlParams", () => { it("flags two filters on the same dimension as complex", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_PUBLISHER_DIMENSION} NIN ('Facebook')`, }), @@ -471,7 +473,7 @@ describe("setUrlParams", () => { // A single chip can only show one of the two, and editing it would leave the mirror on // `Facebook`, so the whole filter falls back to the read only advanced filter. - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Facebook')`, @@ -484,7 +486,7 @@ describe("setUrlParams", () => { it("does not flag the same filter in every metrics view as complex", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, @@ -501,7 +503,7 @@ describe("setUrlParams", () => { filterManager.addNewFilter(AD_BIDS_DOMAIN_DIMENSION); expect(filterManager.temporaryFilterName).toBe(AD_BIDS_DOMAIN_DIMENSION); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), @@ -517,7 +519,7 @@ describe("setUrlParams", () => { describe("applyFilterToParams", () => { it("writes a param per metrics view and drops the legacy singular one", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), ); @@ -538,7 +540,7 @@ describe("applyFilterToParams", () => { it("leaves nothing behind once the filter is removed", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( sharedParam(`${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`), ); filterManager.sortedFilterManagers.dimensions[0].clear(); @@ -557,7 +559,9 @@ describe("applyFilterToParams", () => { [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, }; - filterManager.setUrlParams(perMetricsViewParams(filterByMetricsView)); + filterManager.storeSync.setUrlParams( + perMetricsViewParams(filterByMetricsView), + ); const searchParams = new URLSearchParams(); filterManager.applyFilterToParams(searchParams); @@ -569,7 +573,7 @@ describe("applyFilterToParams", () => { it("writes back a measure filter each metrics view holds on its own", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE} gt 10)`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} having (${AD_BIDS_PUBLISHER_COUNT_MEASURE} lt 5)`, @@ -593,7 +597,7 @@ describe("setExprForMetricsView / setParamForMetricsView", () => { it("sets the filter for one metrics view without touching the others", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( sharedParam(`${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`), ); filterManager.setParamForMetricsView( @@ -677,7 +681,7 @@ describe("setExprForMetricsView / setParamForMetricsView", () => { it("clears the filter for a metrics view when given no expression", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, @@ -699,7 +703,7 @@ describe("chip order", () => { yamlConfigProvider.pinnedFilters = { [AD_BIDS_PUBLISHER_DIMENSION]: true }; const filterManager = createFilterManager(yamlConfigProvider); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, @@ -743,7 +747,7 @@ describe("chip order", () => { it("sorts the param measures by name", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10) AND ${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE} lt 2)`, }), @@ -758,7 +762,7 @@ describe("chip order", () => { it("keys filterManagersMap by chip name", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} having (${AD_BIDS_BID_PRICE_MEASURE} lt 2)`, }), @@ -816,7 +820,7 @@ describe("addNewFilter", () => { it("does not duplicate a chip the param already covers", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), @@ -866,7 +870,7 @@ describe("dimensionFilterAction", () => { it("reuses the manager the param created", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), @@ -899,7 +903,7 @@ describe("dimensionFilterAction", () => { it("does nothing for a measure or an unknown name", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10)`, }), @@ -961,7 +965,7 @@ describe("dimensionFilterAction", () => { describe("with a Contains filter applied", () => { function createWithContainsFilter() { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} LIKE '%oo%'`, }), @@ -1000,7 +1004,7 @@ describe("dimensionFilterAction", () => { it("toggleValue keeps exclude when converting", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} NLIKE '%oo%'`, }), @@ -1058,7 +1062,7 @@ describe("clear", () => { it("removes every chip and the temporary filter", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, @@ -1079,7 +1083,7 @@ describe("clear", () => { yamlConfigProvider.requiredFilters = { [AD_BIDS_DOMAIN_DIMENSION]: true }; const filterManager = createFilterManager(yamlConfigProvider); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, }), @@ -1098,7 +1102,7 @@ describe("clear", () => { describe("getOtherDimensionsFilter", () => { function setupThreeDimensions() { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, @@ -1141,7 +1145,7 @@ describe("getOtherDimensionsFilter", () => { it("returns undefined when no other dimension is filtered", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), @@ -1213,7 +1217,7 @@ describe("exprByMetricsView", () => { it("builds an expression per metrics view", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, [AD_BIDS_MIRROR_METRICS_NAME]: `${AD_BIDS_COUNTRY_DIMENSION} IN ('US')`, @@ -1237,7 +1241,7 @@ describe("exprByMetricsView", () => { it("omits metrics views with no filter", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, }), @@ -1277,7 +1281,7 @@ describe("exprByMetricsView", () => { it("mirrors exprByMetricsView into the svelte 4 store", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_DOMAIN_DIMENSION} IN ('google.com')`, }), @@ -1304,7 +1308,7 @@ describe("filter-changed", () => { it("reports a chip edit with no source", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), @@ -1362,7 +1366,7 @@ describe("filter-changed", () => { it("reports clear with no source", () => { const filterManager = createFilterManager(); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google')`, }), @@ -1378,7 +1382,7 @@ describe("filter-changed", () => { const filterManager = createFilterManager(); const sources = recordSources(filterManager); - filterManager.setUrlParams( + filterManager.storeSync.setUrlParams( perMetricsViewParams({ [AD_BIDS_METRICS_NAME]: `${AD_BIDS_PUBLISHER_DIMENSION} IN ('Google') AND ${AD_BIDS_PUBLISHER_DIMENSION} having (${AD_BIDS_IMPRESSIONS_MEASURE} gt 10)`, }), diff --git a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts index 1ef85a974f3d..aec25edae580 100644 --- a/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/ExpressionFilterManager.svelte.ts @@ -22,7 +22,6 @@ import type { } from "@rilldata/web-common/features/dashboards/filters/filter-events.ts"; import { mergeFilterParams } from "@rilldata/web-common/features/dashboards/filters/expr-utils.ts"; import { getSortFilterManagers } from "@rilldata/web-common/features/dashboards/filters/get-sort-filter-managers.ts"; -import { expandCompressedParams } from "@rilldata/web-common/features/dashboards/url-state/compression.ts"; import type { UrlParamsStore } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; import { UrlParamsChangeTracker } from "@rilldata/web-common/lib/store-utils/url-search-params-store.svelte.ts"; @@ -58,8 +57,6 @@ export class ExpressionFilterManager implements UrlParamsStore { }; public readonly filterManagersMap: Record; - private curParams = $state(undefined); - // Shared with every manager below this one, so a chip edit reports itself // without the managers in between having to forward it. See `filter-events.ts`. private events = new EventEmitter(); @@ -67,11 +64,7 @@ export class ExpressionFilterManager implements UrlParamsStore { this.events, ) as typeof this.events.on; - // Temporary lock in explore. Once we move whereFilter out of explore, we can remove this. - public updating = false; - public ready = $state(false); - public dataLoaded = $state(false); public paramKeys = new Set([ExploreStateURLParams.Filters]); public readonly storeSync: UrlParamsChangeTracker; @@ -82,14 +75,17 @@ export class ExpressionFilterManager implements UrlParamsStore { private readonly singleParamFormMv = false, ) { this.storeSync = new UrlParamsChangeTracker(this); - metricsViewsProvider.on("update-metrics-views", (newMetricsViewsNames) => { + + const syncParamKeys = (names: string[]) => { this.paramKeys = new Set([ ExploreStateURLParams.Filters, - ...newMetricsViewsNames.map((mvName) => - getParamKeyForMv(mvName, singleParamFormMv), - ), + ...names.map((mvName) => getParamKeyForMv(mvName, singleParamFormMv)), ]); - }); + }; + metricsViewsProvider.on("update-metrics-views", syncParamKeys); + syncParamKeys(metricsViewsProvider.metricsViewNames); + + this.ready = metricsViewsProvider.ready; metricsViewsProvider.on("specs-loaded", () => { this.ready = true; this.events.emit("ready"); @@ -104,9 +100,9 @@ export class ExpressionFilterManager implements UrlParamsStore { this.events, ) as JoinerFilterManager, ); - this.on("filter-removed", ({ name, wasEmpty }) => { - if (!wasEmpty) return; // This will change expr and other pipelines will update managers. - this.topLevelJoiner.removeManagerByName(name); + this.events.on("filter-changed", () => { + this.topLevelJoiner?.removeEmptyManagers(); + this.storeSync.stateChanged(); }); this.sortedFilterManagers = $derived.by(() => @@ -150,16 +146,8 @@ export class ExpressionFilterManager implements UrlParamsStore { } public normalizeParams(urlParams: URLSearchParams): URLSearchParams { - let expandedUrlParams: URLSearchParams; - try { - expandedUrlParams = expandCompressedParams(urlParams); - } catch { - // If we fail to decompress, do not throw here. - return urlParams; - } - - const singularParam = expandedUrlParams.get(ExploreStateURLParams.Filters); - if (!singularParam || this.singleParamFormMv) return expandedUrlParams; + const singularParam = urlParams.get(ExploreStateURLParams.Filters); + if (!singularParam || this.singleParamFormMv) return urlParams; const newUrlParams = new URLSearchParams(); this.metricsViewsProvider.metricsViewNames.forEach((mvName) => @@ -187,7 +175,7 @@ export class ExpressionFilterManager implements UrlParamsStore { public setParamForMetricsView(mvName: string, param: string) { const paramKey = getParamKeyForMv(mvName, this.singleParamFormMv); - const newParams = new URLSearchParams(this.curParams); + const newParams = new URLSearchParams(this.storeSync.searchParams); newParams.set(paramKey, param); // Thread through the sync code to ensure only changes update the internal state. this.storeSync.setUrlParams(newParams); diff --git a/web-common/src/features/dashboards/filters/JoinerFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/JoinerFilterManager.svelte.ts index 9b83fa42c1f0..a551c3ff9c6d 100644 --- a/web-common/src/features/dashboards/filters/JoinerFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/JoinerFilterManager.svelte.ts @@ -268,6 +268,18 @@ export class JoinerFilterManager { }; } + public removeEmptyManagers() { + this.managers = { + ...this.managers, + dimensionManagers: this.managers.dimensionManagers.filter( + (dfm) => !!dfm.expr, + ), + measureManagers: this.managers.measureManagers.filter( + (mfm) => !!mfm.expr, + ), + }; + } + public removeManagerByName(name: string) { this.managers = { ...this.managers, diff --git a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts index 586713e0bc8a..d43f1445bd92 100644 --- a/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/dimension-filters/DimensionFilterManager.svelte.ts @@ -209,12 +209,7 @@ export class DimensionFilterManager { public clear() { this.selectedValues = []; this.inputText = ""; - const wasEmpty = this.expr === undefined; this.commit(); - this.events?.emit("filter-removed", { - name: this.name, - wasEmpty, - }); } /** diff --git a/web-common/src/features/dashboards/filters/filter-events.ts b/web-common/src/features/dashboards/filters/filter-events.ts index a710f0326645..0aeb07f9b93e 100644 --- a/web-common/src/features/dashboards/filters/filter-events.ts +++ b/web-common/src/features/dashboards/filters/filter-events.ts @@ -10,9 +10,6 @@ export type FilterChangeSource = string | undefined; export type FilterEvents = { // A filter was mutated. Emitted synchronously by the manager that was mutated. "filter-changed": { source: FilterChangeSource }; - // Emitted when a filter is manually removed from a manager. - // This is useful when removal doesn't change expr and the manager has to be manually removed. - "filter-removed": { name: string; wasEmpty: boolean }; ready: void; }; diff --git a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts index 0b957834f26f..4644fe91c15f 100644 --- a/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/filters/measure-filters/MeasureFilterManager.svelte.ts @@ -99,12 +99,7 @@ export class MeasureFilterManager { this.value1 = ""; this.value2 = ""; this.dimension = ""; - const wasEmpty = this.expr === undefined; this.commit(); - this.events?.emit("filter-removed", { - name: this.name, - wasEmpty, - }); } /** diff --git a/web-common/src/features/dashboards/filters/test/expression-filters-suite.ts b/web-common/src/features/dashboards/filters/test/expression-filters-suite.ts index 97ec5e7ce34c..66a940212dbc 100644 --- a/web-common/src/features/dashboards/filters/test/expression-filters-suite.ts +++ b/web-common/src/features/dashboards/filters/test/expression-filters-suite.ts @@ -45,6 +45,7 @@ import { } from "@rilldata/web-common/runtime-client"; import { screen, waitFor } from "@testing-library/svelte"; import { describe, expect, it } from "vitest"; +import { asyncWait } from "@rilldata/web-common/lib/waitUtils.ts"; /** * How a test reads the filter state back. @@ -998,6 +999,7 @@ export function testURLNavigationFlows(variant: ExpressionFiltersVariant) { it("Should apply initial state based on url search", async () => { await variant.render(urlAfterBothFilters); + console.log("Start..."); // Filters already applied. await waitFor(() => { 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 c61d932dcaf7..1a5817a3cc32 100644 --- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts +++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts @@ -84,6 +84,9 @@ export class DashboardStateSync { dataLoader.fullTimeRangeQuery, ); + this.expressionFilterParamsTracker = expressionFilterManager.storeSync; + this.timeFilterParamsTracker = timeFilterManager.storeSync; + this.unsubInit = derived( [dataLoader.initExploreState], (states) => states, @@ -103,8 +106,6 @@ export class DashboardStateSync { void this.gotoNewState(exploreState); }); - this.expressionFilterParamsTracker = expressionFilterManager.storeSync; - // this.timeFilterParamsTracker = timeFilterManager.storeSync; setContext(DASHBOARD_STATE_SYNC_KEY, this); } @@ -312,12 +313,17 @@ export class DashboardStateSync { ); } - // Merge the partial state from url into the store + // Merge the partial state from url into the stores + this.expressionFilterParamsTracker.setUrlParams(urlSearchParams); + this.timeFilterParamsTracker.setUrlParams(urlSearchParams); + metricsExplorerStore.mergePartialExplorerEntity( this.exploreName, partialExplore, this.expressionFilterManager, + this.timeFilterManager, ); + // Get time controls state after explore state is updated. const timeControlsState = get(this.timeControlStore); // Get the updated URL, this could be different from the page url if we added extra state. @@ -351,12 +357,6 @@ export class DashboardStateSync { // Release before the goto below: state changes made while the navigation is in flight // must still be picked up by gotoNewState. this.updating = false; - if (redirectUrl) { - this.expressionFilterParamsTracker.setUrlParams( - redirectUrl.searchParams, - ); - this.timeFilterParamsTracker.setUrlParams(redirectUrl.searchParams); - } } // Try-finally without a catch. Rest of the code is not run if the above try body throws. 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 d2b9e59b1a0c..af2d9a22c244 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -182,8 +182,7 @@ export function createStateManagers({ dashboardConfigProvider.metricsViewsProvider, dashboardConfigProvider.yamlConfigProvider, ); - expressionFilterManager.storeSync.on("change", () => { - if (expressionFilterManager.updating) return; + expressionFilterManager.storeSync.on("internal-change", () => { metricsExplorerStore.syncExpressionFilter( exploreName, expressionFilterManager, @@ -194,11 +193,14 @@ export function createStateManagers({ runtimeClient, dashboardConfigProvider.metricsViewsProvider, dashboardConfigProvider.yamlConfigProvider, - true, - true, + { + allowCustomTimeRange: true, + skipDefaultTimeRange: false, + skipTimeGrain: false, + log: true, + }, ); timeFilterManager.storeSync.on("change", () => { - if (timeFilterManager.updating) return; metricsExplorerStore.syncTimeFilters(exploreName, timeFilterManager); }); diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts index ad6053c1b413..3bf8fdf40efc 100644 --- a/web-common/src/features/dashboards/stores/dashboard-stores.ts +++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts @@ -243,6 +243,7 @@ const metricsViewReducers = { name: string, partialExploreState: Partial, expressionFilterManager: ExpressionFilterManager, + timeFilterManager?: TimeFilterManager, ) { partialExploreState = structuredClone(partialExploreState); @@ -261,6 +262,9 @@ const metricsViewReducers = { expressionFilterManager.inList; } + if (timeFilterManager) + setFromTimeManager(exploreState, timeFilterManager); + // 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) { @@ -292,63 +296,28 @@ const metricsViewReducers = { updateMetricsExplorerByName(name, (exploreState) => { const mvName = expressionFilterManager.metricsViewsProvider.metricsViewNames[0]; - if (mvName) { - const newWhereFilter = - expressionFilterManager.topLevelJoiner.expr[mvName] ?? - createAndExpression([]); - // Read before whereFilter is replaced, since the pin moves with the values it points at. - exploreState.tdd.pinIndex = getUpdatedPinIndex( - exploreState.tdd.pinIndex, - exploreState.selectedComparisonDimension, - exploreState.whereFilter, - newWhereFilter, - ); + if (!mvName) return; + + const newWhereFilter = + expressionFilterManager.topLevelJoiner.expr[mvName] ?? + createAndExpression([]); + // Read before whereFilter is replaced, since the pin moves with the values it points at. + exploreState.tdd.pinIndex = getUpdatedPinIndex( + exploreState.tdd.pinIndex, + exploreState.selectedComparisonDimension, + exploreState.whereFilter, + newWhereFilter, + ); - exploreState.whereFilter = newWhereFilter; - exploreState.dimensionsWithInlistFilter = - expressionFilterManager.inList; - } + exploreState.whereFilter = newWhereFilter; + exploreState.dimensionsWithInlistFilter = expressionFilterManager.inList; }); }, syncTimeFilters(name: string, timeFilterManager: TimeFilterManager) { if (!name) return; updateMetricsExplorerByName(name, (exploreState) => { - if (!timeFilterManager.timeRange) return; - - exploreState.selectedTimeRange = { - name: timeFilterManager.timeRange, - start: timeFilterManager.interval?.start?.toJSDate() ?? new Date(), - end: timeFilterManager.interval?.end?.toJSDate() ?? new Date(), - interval: timeFilterManager.timeGrain, - } as any; - exploreState.showTimeComparison = timeFilterManager.showComparison; - exploreState.selectedComparisonTimeRange = { - name: timeFilterManager.comparisonTimeRange, - start: - timeFilterManager.comparisonInterval?.start?.toJSDate() ?? new Date(), - end: - timeFilterManager.comparisonInterval?.end?.toJSDate() ?? new Date(), - }; - - if (timeFilterManager.scrubInterval) { - exploreState.selectedScrubRange = { - start: timeFilterManager.scrubInterval.start.toJSDate(), - end: timeFilterManager.scrubInterval.end.toJSDate(), - isScrubbing: timeFilterManager.scrubInterval.isScrubbing, - }; - } else { - exploreState.selectedScrubRange = undefined; - } - if (timeFilterManager.lastDefinedScrubInterval) { - exploreState.lastDefinedScrubRange = { - start: timeFilterManager.lastDefinedScrubInterval.start.toJSDate(), - end: timeFilterManager.lastDefinedScrubInterval.end.toJSDate(), - isScrubbing: false, - }; - } else { - exploreState.lastDefinedScrubRange = undefined; - } + setFromTimeManager(exploreState, timeFilterManager); }); }, @@ -896,6 +865,47 @@ function setSelectedScrubRange( exploreState.selectedScrubRange = scrubRange; } +function setFromTimeManager( + exploreState: ExploreState, + timeFilterManager: TimeFilterManager, +) { + console.log(timeFilterManager.timeRange, exploreState.selectedTimeRange); + if (!timeFilterManager.timeRange) return; + + exploreState.selectedTimeRange = { + name: timeFilterManager.timeRange, + start: timeFilterManager.interval?.start?.toJSDate() ?? new Date(), + end: timeFilterManager.interval?.end?.toJSDate() ?? new Date(), + interval: timeFilterManager.timeGrain, + } as any; + exploreState.showTimeComparison = timeFilterManager.showComparison; + exploreState.selectedComparisonTimeRange = { + name: timeFilterManager.comparisonTimeRange, + start: + timeFilterManager.comparisonInterval?.start?.toJSDate() ?? new Date(), + end: timeFilterManager.comparisonInterval?.end?.toJSDate() ?? new Date(), + }; + + if (timeFilterManager.scrubInterval) { + exploreState.selectedScrubRange = { + start: timeFilterManager.scrubInterval.start.toJSDate(), + end: timeFilterManager.scrubInterval.end.toJSDate(), + isScrubbing: timeFilterManager.scrubInterval.isScrubbing, + }; + } else { + exploreState.selectedScrubRange = undefined; + } + if (timeFilterManager.lastDefinedScrubInterval) { + exploreState.lastDefinedScrubRange = { + start: timeFilterManager.lastDefinedScrubInterval.start.toJSDate(), + end: timeFilterManager.lastDefinedScrubInterval.end.toJSDate(), + isScrubbing: false, + }; + } else { + exploreState.lastDefinedScrubRange = undefined; + } +} + function getPinIndexForDimension( exploreState: ExploreState, dimensionName: string, diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts new file mode 100644 index 000000000000..9c319630ad16 --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts @@ -0,0 +1,1106 @@ +import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; +import { + AD_BIDS_METRICS_INIT, + AD_BIDS_METRICS_INIT_WITH_TIME, + AD_BIDS_METRICS_NAME, + AD_BIDS_TIMESTAMP_DIMENSION, +} from "@rilldata/web-common/features/dashboards/stores/test-data/data"; +import { + deriveInterval, + INHERIT_TIME_RANGE_ALIAS, +} from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts"; +import { + DEFAULT_TIME_RANGE, + RESOLVED_RILL_TIMES, + TIME_RANGE_SUMMARY, +} from "@rilldata/web-common/features/dashboards/time-controls/test/rill-time-mocks"; +import type { TimeFiltersConfig } from "@rilldata/web-common/features/dashboards/time-controls/time-filters-config.ts"; +import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; +import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts"; +import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts"; +import { + createInEffectRoot, + renderInRuntimeContext, + useMetricsViewMocks, +} from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; +import { + TimeComparisonOption, + TimeRangePreset, +} from "@rilldata/web-common/lib/time/types.ts"; +import { asyncWait, waitUntil } from "@rilldata/web-common/lib/waitUtils.ts"; +import { V1TimeGrain } from "@rilldata/web-common/runtime-client"; +import { DateTime } from "luxon"; +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.stubEnv("TZ", "UTC"); + +// Wrapped so that a test can tell whether a range was resolved again. +vi.mock( + "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts") + >(); + return { ...actual, deriveInterval: vi.fn(actual.deriveInterval) }; + }, +); + +// --------------------------------------------------------------------------- +// Test metrics views +// +// AdBids has a time dimension and resolves rilltime expressions to the intervals in +// `RESOLVED_RILL_TIMES`. Its mirror resolves the default range to a window ending a day later, +// which makes the choice of interval across metrics views observable. +// The third metrics view has no time dimension at all. +// --------------------------------------------------------------------------- + +const AD_BIDS_MIRROR_METRICS_NAME = "AdBids_mirror_metrics"; +const AD_BIDS_NO_TIME_METRICS_NAME = "AdBids_no_time_metrics"; + +const mocks = useMetricsViewMocks({ + [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT_WITH_TIME, + [AD_BIDS_MIRROR_METRICS_NAME]: AD_BIDS_METRICS_INIT_WITH_TIME, + [AD_BIDS_NO_TIME_METRICS_NAME]: AD_BIDS_METRICS_INIT, +}); +mocks.mockTimeRangeSummary(AD_BIDS_METRICS_NAME, TIME_RANGE_SUMMARY); +mocks.mockResolvedRillTimes(AD_BIDS_METRICS_NAME, { + ...RESOLVED_RILL_TIMES, + // The default picked from the time range summary when the yaml has none: + // the summary spans about 90 days, which lands on quarter to date. + [TimeRangePreset.QUARTER_TO_DATE]: { + start: "2024-01-01T00:00:00.000Z", + end: "2024-04-01T00:00:00.000Z", + grain: V1TimeGrain.TIME_GRAIN_DAY, + }, +}); +mocks.mockTimeRangeSummary(AD_BIDS_MIRROR_METRICS_NAME, TIME_RANGE_SUMMARY); +mocks.mockResolvedRillTimes(AD_BIDS_MIRROR_METRICS_NAME, { + [DEFAULT_TIME_RANGE]: { + start: "2024-03-26T00:00:00.000Z", + end: "2024-04-02T00:00:00.000Z", + grain: V1TimeGrain.TIME_GRAIN_DAY, + }, +}); + +// Each test gets its own provider and manager. +// The queries behind the provider are cached globally, so only the first provider waits on fetches. +let metricsViewsProvider: MetricsViewsProvider; +const cleanups: (() => void)[] = []; + +beforeEach(async () => { + metricsViewsProvider = await createReadyMetricsViewsProvider([ + AD_BIDS_METRICS_NAME, + ]); +}); + +afterEach(() => { + cleanups.splice(0).forEach((destroy) => destroy()); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type CreateOptions = { + provider?: MetricsViewsProvider; + yamlConfigProvider?: YAMLConfigProvider; + config?: TimeFiltersConfig; + parent?: TimeFilterManager; +}; + +/** + * A provider over `metricsViewNames`, torn down after the test. + * Resolves once the specs and the time range summaries have landed. + */ +async function createReadyMetricsViewsProvider(metricsViewNames: string[]) { + // MetricsViewsProvider calls createQuery, so it has to be created inside a component. + const rendered = renderInRuntimeContext( + ({ runtimeClient }) => + new MetricsViewsProvider(runtimeClient, metricsViewNames), + ); + cleanups.push(() => { + rendered.value.cleanup(); + rendered.destroy(); + }); + expect(await waitUntil(() => rendered.value.ready, 2000, 5)).toBe(true); + return rendered.value; +} + +/** A yaml config with the default time range the dashboards in `rill-time-mocks.ts` start on. */ +function yamlWithDefaultTimeRange() { + const yamlConfigProvider = new YAMLConfigProvider(); + yamlConfigProvider.defaultTimeRange = DEFAULT_TIME_RANGE; + return yamlConfigProvider; +} + +/** A real time filter manager over a ready provider, torn down after the test. */ +function createTimeFilterManager({ + provider = metricsViewsProvider, + yamlConfigProvider = yamlWithDefaultTimeRange(), + config = {}, + parent, +}: CreateOptions = {}) { + const { value, destroy } = createInEffectRoot( + () => + new TimeFilterManager( + provider.runtimeClient, + provider, + yamlConfigProvider, + config, + parent, + ), + ); + cleanups.push(destroy); + return value; +} + +async function waitForReady(manager: TimeFilterManager) { + expect(await waitUntil(() => manager.ready, 2000, 5)).toBe(true); +} + +/** Resolves once `timeRange` has been resolved by the runtime and applied. */ +async function waitForTimeRange( + manager: TimeFilterManager, + timeRange: string | undefined, +) { + const applied = await waitUntil( + () => manager.timeRange === timeRange && !!manager.interval, + 2000, + 5, + ); + expect(applied, `time range to be "${timeRange}"`).toBe(true); +} + +/** Records every param set the manager reports to the url. */ +function recordChanges(manager: TimeFilterManager) { + const changes: Record[] = []; + cleanups.push( + manager.storeSync.on("change", (params) => { + changes.push(Object.fromEntries(params)); + }), + ); + return changes; +} + +/** `stateChanged` reports on the next microtask. */ +function flushStateChanges() { + return asyncWait(0); +} + +function paramsOf(manager: TimeFilterManager) { + const params = new URLSearchParams(); + manager.applyFilterToParams(params); + return Object.fromEntries(params); +} + +function isoOf(dateTime: DateTime | undefined) { + return dateTime?.toJSDate().toISOString(); +} + +function intervalOf(manager: TimeFilterManager) { + return { + start: isoOf(manager.interval?.start), + end: isoOf(manager.interval?.end), + }; +} + +function comparisonIntervalOf(manager: TimeFilterManager) { + return { + start: isoOf(manager.comparisonInterval?.start), + end: isoOf(manager.comparisonInterval?.end), + }; +} + +function resolvedIntervalOf(timeRange: string) { + const { start, end } = RESOLVED_RILL_TIMES[timeRange]; + return { start, end }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("setUrlParams", () => { + it("applies the yaml default time range when the url has none", async () => { + const manager = createTimeFilterManager(); + + // The tracker skips params equal to the ones it holds, and it starts out empty, + // so an empty url goes to the manager directly. + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect(manager.urlTimeRange).toBe(DEFAULT_TIME_RANGE); + expect(intervalOf(manager)).toEqual(resolvedIntervalOf(DEFAULT_TIME_RANGE)); + // The grain comes from the snap of the `as of` clause. + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY); + expect(manager.apiTimeRange).toEqual({ + ...resolvedIntervalOf(DEFAULT_TIME_RANGE), + timeZone: "UTC", + timeDimension: undefined, + }); + }); + + it("resolves the time range in the url", async () => { + const manager = createTimeFilterManager(); + + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: "4W as of latest/D+1D" }), + ); + await waitForTimeRange(manager, "4W as of latest/D+1D"); + + expect(intervalOf(manager)).toEqual( + resolvedIntervalOf("4W as of latest/D+1D"), + ); + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY); + }); + + it("reads the grain, time zone and time dimension from the url", async () => { + const manager = createTimeFilterManager(); + + manager.storeSync.setUrlParams( + new URLSearchParams({ + tr: "4W as of latest/D+1D", + grain: "week", + tz: "America/New_York", + td: AD_BIDS_TIMESTAMP_DIMENSION, + }), + ); + await waitForTimeRange(manager, "4W as of latest/D+1D"); + + // Week is a grain the 4 week interval allows, so the url grain wins over the snap. + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_WEEK); + expect(manager.timeZone).toBe("America/New_York"); + expect(manager.interval?.start.zoneName).toBe("America/New_York"); + expect(manager.apiTimeRange).toEqual({ + ...resolvedIntervalOf("4W as of latest/D+1D"), + timeZone: "America/New_York", + timeDimension: AD_BIDS_TIMESTAMP_DIMENSION, + }); + }); + + it("falls back to the snap grain when the url grain is not allowed for the interval", async () => { + const manager = createTimeFilterManager(); + + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: "12h as of latest/h+1h", grain: "month" }), + ); + await waitForTimeRange(manager, "12h as of latest/h+1h"); + + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_HOUR); + }); + + it("falls back to the yaml default time zone", async () => { + const yamlConfigProvider = yamlWithDefaultTimeRange(); + yamlConfigProvider.defaultTimeZone = "Asia/Kathmandu"; + const manager = createTimeFilterManager({ yamlConfigProvider }); + + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect(manager.timeZone).toBe("Asia/Kathmandu"); + expect(manager.apiTimeRange.timeZone).toBe("Asia/Kathmandu"); + }); + + it("computes the contiguous comparison even when comparison is off", async () => { + const manager = createTimeFilterManager(); + + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect(manager.showComparison).toBe(false); + expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.CONTIGUOUS); + // The 7 days right before the selected ones. + expect(comparisonIntervalOf(manager)).toEqual({ + start: "2024-03-18T00:00:00.000Z", + end: "2024-03-25T00:00:00.000Z", + }); + expect(manager.apiComparisonTimeRange).toEqual({ + start: "2024-03-18T00:00:00.000Z", + end: "2024-03-25T00:00:00.000Z", + timeZone: "UTC", + }); + }); + + it("turns comparison on from the url", async () => { + const manager = createTimeFilterManager(); + + manager.storeSync.setUrlParams( + new URLSearchParams({ + tr: DEFAULT_TIME_RANGE, + compare_tr: TimeComparisonOption.WEEK, + }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect(manager.showComparison).toBe(true); + expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.WEEK); + expect(comparisonIntervalOf(manager)).toEqual({ + start: "2024-03-18T00:00:00.000Z", + end: "2024-03-25T00:00:00.000Z", + }); + }); + + it("leaves the time range empty with skipDefaultTimeRange", async () => { + const manager = createTimeFilterManager({ + config: { skipDefaultTimeRange: true }, + }); + + manager.setUrlParams(new URLSearchParams()); + await asyncWait(20); + + expect(manager.urlTimeRange).toBeUndefined(); + expect(manager.timeRange).toBeUndefined(); + expect(manager.interval).toBeUndefined(); + expect(manager.apiTimeRange.start).toBeUndefined(); + expect(manager.apiTimeRange.end).toBeUndefined(); + }); + + it("clears a previous time range once the url drops it with skipDefaultTimeRange", async () => { + const manager = createTimeFilterManager({ + config: { skipDefaultTimeRange: true }, + }); + + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + manager.storeSync.setUrlParams(new URLSearchParams()); + + expect(manager.timeRange).toBeUndefined(); + expect(manager.interval).toBeUndefined(); + }); + + it("reads the highlighted range from the url", async () => { + const manager = createTimeFilterManager(); + + manager.storeSync.setUrlParams( + new URLSearchParams({ + tr: DEFAULT_TIME_RANGE, + highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z", + }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + // Applying the time range clears the scrub, which must not drop the one in the url. + expect(manager.timeStart).toBe("2024-03-26T00:00:00.000Z"); + expect(manager.timeEnd).toBe("2024-03-27T00:00:00.000Z"); + expect(manager.scrubInterval?.isScrubbing).toBe(false); + // The selected range itself is untouched. + expect(intervalOf(manager)).toEqual(resolvedIntervalOf(DEFAULT_TIME_RANGE)); + expect(paramsOf(manager).highlighted_tr).toBe( + "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z", + ); + }); + + it("clears the highlighted range once the url drops it", async () => { + const manager = createTimeFilterManager({ + config: { skipDefaultTimeRange: true }, + }); + manager.storeSync.setUrlParams( + new URLSearchParams({ + tr: DEFAULT_TIME_RANGE, + highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z", + }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + // No time range is applied here, so nothing else clears the scrub. + manager.storeSync.setUrlParams(new URLSearchParams({ grain: "day" })); + + expect(manager.scrubInterval).toBeUndefined(); + expect(manager.lastDefinedScrubInterval).toBeUndefined(); + }); + + it("ignores the url grain with skipTimeGrain", async () => { + const manager = createTimeFilterManager({ + config: { skipTimeGrain: true }, + }); + + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: "4W as of latest/D+1D", grain: "week" }), + ); + await waitForTimeRange(manager, "4W as of latest/D+1D"); + + // The resolved grain is still tracked, it is just not read from or written to the url. + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY); + expect(paramsOf(manager)).not.toHaveProperty( + ExploreStateURLParams.TimeGrain, + ); + }); +}); + +describe("delayed load", () => { + /** + * A manager created before its provider has any data, as on a dashboard's first load. + * `beforeSpecsLoad` runs right after the manager is created. + */ + function createBeforeLoad( + yamlConfigProvider: YAMLConfigProvider, + beforeSpecsLoad: (manager: TimeFilterManager) => void, + ) { + // Drop the responses cached by the `beforeEach` provider so that this one has to fetch. + queryClient.clear(); + const rendered = renderInRuntimeContext(({ runtimeClient }) => { + const provider = new MetricsViewsProvider(runtimeClient, [ + AD_BIDS_METRICS_NAME, + ]); + const manager = new TimeFilterManager( + runtimeClient, + provider, + yamlConfigProvider, + ); + beforeSpecsLoad(manager); + return { provider, manager }; + }); + cleanups.push(() => { + rendered.value.provider.cleanup(); + rendered.destroy(); + }); + return rendered.value.manager; + } + + it("holds the url params until the time range summary lands", async () => { + const manager = createBeforeLoad(yamlWithDefaultTimeRange(), (manager) => + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: "4W as of latest/D+1D", grain: "week" }), + ), + ); + expect(manager.ready).toBe(false); + expect(manager.urlTimeRange).toBeUndefined(); + + await waitForReady(manager); + await waitForTimeRange(manager, "4W as of latest/D+1D"); + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_WEEK); + }); + + it("picks the default time range from the time range summary once it lands", async () => { + // Without a yaml default, the default depends on the span of the data, + // so it cannot be picked before the summary is loaded. + const manager = createBeforeLoad(new YAMLConfigProvider(), (manager) => + manager.storeSync.setUrlParams( + new URLSearchParams({ tz: "America/New_York" }), + ), + ); + expect(manager.ready).toBe(false); + + await waitForReady(manager); + await waitForTimeRange(manager, TimeRangePreset.QUARTER_TO_DATE); + expect(manager.timeZone).toBe("America/New_York"); + expect(intervalOf(manager)).toEqual({ + start: "2024-01-01T00:00:00.000Z", + end: "2024-04-01T00:00:00.000Z", + }); + }); +}); + +describe("applyFilterToParams", () => { + it("writes the time params back", async () => { + const manager = createTimeFilterManager(); + const params = { + tr: "4W as of latest/D+1D", + grain: "week", + tz: "America/New_York", + td: AD_BIDS_TIMESTAMP_DIMENSION, + compare_tr: TimeComparisonOption.WEEK, + }; + + manager.storeSync.setUrlParams(new URLSearchParams(params)); + await waitForTimeRange(manager, "4W as of latest/D+1D"); + + expect(paramsOf(manager)).toEqual(params); + }); + + it("omits the default time zone and a comparison that is off", async () => { + const manager = createTimeFilterManager(); + + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE, tz: "UTC" }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + // The comparison range is still set internally, it is just not shown. + expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.CONTIGUOUS); + expect(paramsOf(manager)).toEqual({ + tr: DEFAULT_TIME_RANGE, + grain: "day", + }); + }); + + it("removes params the manager no longer holds", () => { + const manager = createTimeFilterManager({ + config: { skipDefaultTimeRange: true }, + }); + manager.setUrlParams(new URLSearchParams()); + + const searchParams = new URLSearchParams({ + tr: DEFAULT_TIME_RANGE, + tz: "America/New_York", + td: AD_BIDS_TIMESTAMP_DIMENSION, + compare_tr: TimeComparisonOption.WEEK, + highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z", + // Not a time param, so it is left alone. + view: "tdd", + }); + manager.applyFilterToParams(searchParams); + + expect(Object.fromEntries(searchParams)).toEqual({ view: "tdd" }); + }); +}); + +describe("onSelectRange", () => { + it("keeps the snap of the current range for a coarser range", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const changes = recordChanges(manager); + + await manager.onSelectRange("4W"); + await flushStateChanges(); + + expect(manager.timeRange).toBe("4W as of latest/D+1D"); + expect(intervalOf(manager)).toEqual( + resolvedIntervalOf("4W as of latest/D+1D"), + ); + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY); + // One url update per selection. + expect(changes).toEqual([{ tr: "4W as of latest/D+1D", grain: "day" }]); + }); + + it("narrows the snap to the grain of a finer range", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + await manager.onSelectRange("12h"); + + expect(manager.timeRange).toBe("12h as of latest/h+1h"); + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_HOUR); + }); + + it("recomputes the comparison for the new range", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ + tr: DEFAULT_TIME_RANGE, + compare_tr: TimeComparisonOption.WEEK, + }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + await manager.onSelectRange("24h"); + + expect(manager.timeRange).toBe("24h as of latest/h+1h"); + expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.WEEK); + expect(comparisonIntervalOf(manager)).toEqual({ + start: "2024-03-23T15:00:00.000Z", + end: "2024-03-24T15:00:00.000Z", + }); + }); + + it("clears the scrubbed range", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + manager.onScrubRange({ + start: DateTime.fromISO("2024-03-26T00:00:00.000Z"), + end: DateTime.fromISO("2024-03-27T00:00:00.000Z"), + isScrubbing: false, + }); + + await manager.onSelectRange("4W"); + + expect(manager.scrubInterval).toBeUndefined(); + expect(manager.lastDefinedScrubInterval).toBeUndefined(); + }); +}); + +describe("onSelectAsOfOption", () => { + it("moves the snap offset to the start of the grain", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + await manager.onSelectAsOfOption(manager.ref, false); + + expect(manager.timeRange).toBe("7D as of latest/D"); + expect(intervalOf(manager)).toEqual( + resolvedIntervalOf("7D as of latest/D"), + ); + expect(manager.snapToEnd).toBe(false); + }); +}); + +describe("parsed time range", () => { + it("exposes the reference, snap and truncation grain of the range", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect(manager.ref).toBe("latest"); + expect(manager.snapToEnd).toBe(true); + expect(manager.truncationGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY); + }); +}); + +describe("onSelectGrain", () => { + it("sets the grain and reports it to the url", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: "4W as of latest/D+1D" }), + ); + await waitForTimeRange(manager, "4W as of latest/D+1D"); + const changes = recordChanges(manager); + + manager.onSelectGrain(V1TimeGrain.TIME_GRAIN_WEEK); + await flushStateChanges(); + + expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_WEEK); + expect(changes).toEqual([{ tr: "4W as of latest/D+1D", grain: "week" }]); + }); +}); + +describe("onSelectTimeDimension", () => { + it("sets the time dimension and reports it to the url", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const changes = recordChanges(manager); + + manager.onSelectTimeDimension(AD_BIDS_TIMESTAMP_DIMENSION); + await flushStateChanges(); + + expect(manager.apiTimeRange.timeDimension).toBe( + AD_BIDS_TIMESTAMP_DIMENSION, + ); + expect(changes).toEqual([ + { tr: DEFAULT_TIME_RANGE, grain: "day", td: AD_BIDS_TIMESTAMP_DIMENSION }, + ]); + }); + + it("resolves the range again against the new time dimension", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + vi.mocked(deriveInterval).mockClear(); + + manager.onSelectTimeDimension(AD_BIDS_TIMESTAMP_DIMENSION); + await asyncWait(20); + + expect(deriveInterval).toHaveBeenCalledWith( + DEFAULT_TIME_RANGE, + expect.anything(), + AD_BIDS_METRICS_NAME, + "UTC", + AD_BIDS_TIMESTAMP_DIMENSION, + undefined, + ); + }); +}); + +describe("onSelectZone", () => { + it("sets the time zone and reports it to the url", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const changes = recordChanges(manager); + + manager.onSelectZone("America/New_York"); + await flushStateChanges(); + + expect(manager.timeZone).toBe("America/New_York"); + expect(manager.apiTimeRange.timeZone).toBe("America/New_York"); + expect(changes).toEqual([ + { tr: DEFAULT_TIME_RANGE, grain: "day", tz: "America/New_York" }, + ]); + }); + + it("resolves the range again in the new time zone", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + manager.onSelectZone("America/New_York"); + await asyncWait(20); + + expect(manager.interval?.start.zoneName).toBe("America/New_York"); + }); +}); + +describe("pan", () => { + it("allows panning both ways for a range inside the data", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: "7D as of latest/D" }), + ); + await waitForTimeRange(manager, "7D as of latest/D"); + + // 2024-03-24 to 2024-03-31, inside 2024-01-01 to 2024-03-31T14:30. + expect(manager.canPanLeft).toBe(true); + expect(manager.canPanRight).toBe(true); + }); + + it("does not allow panning right past the end of the data", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + // Padded to the end of the day, so it ends after the data does. + expect(manager.canPanLeft).toBe(true); + expect(manager.canPanRight).toBe(false); + }); +}); + +describe("comparison", () => { + it("onSelectComparisonRange turns comparison on with the selected range", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const changes = recordChanges(manager); + + manager.onSelectComparisonRange(TimeComparisonOption.WEEK); + await flushStateChanges(); + + expect(manager.showComparison).toBe(true); + expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.WEEK); + expect(comparisonIntervalOf(manager)).toEqual({ + start: "2024-03-18T00:00:00.000Z", + end: "2024-03-25T00:00:00.000Z", + }); + expect(changes).toEqual([ + { + tr: DEFAULT_TIME_RANGE, + grain: "day", + compare_tr: TimeComparisonOption.WEEK, + }, + ]); + }); + + it("onToggleShowComparison and setShowComparison flip the comparison", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const changes = recordChanges(manager); + + manager.onToggleShowComparison(); + await flushStateChanges(); + expect(manager.showComparison).toBe(true); + + manager.setShowComparison(false); + await flushStateChanges(); + expect(manager.showComparison).toBe(false); + + expect(changes).toEqual([ + { + tr: DEFAULT_TIME_RANGE, + grain: "day", + compare_tr: TimeComparisonOption.CONTIGUOUS, + }, + { tr: DEFAULT_TIME_RANGE, grain: "day" }, + ]); + }); + + it("switches to the contiguous comparison for an absolute time range", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + // Absolute ranges are not resolved by the runtime mock, so set the range directly. + manager.timeRange = "2024-03-01T00:00:00Z to 2024-03-08T00:00:00Z"; + + manager.onSelectComparisonRange(TimeComparisonOption.WEEK); + + expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.CONTIGUOUS); + }); + + it("offers the comparisons that fit in the data", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + // For 7 days, the previous period is the previous week, so only the latter is listed. + // The previous day is shorter than the range, + // and the previous quarter and year start before the data does on 2024-01-01. + expect(manager.comparisonTimeRangeOptions.map(({ name }) => name)).toEqual([ + TimeComparisonOption.WEEK, + TimeComparisonOption.MONTH, + ]); + }); + + it("offers only the yaml comparisons for a yaml time range", async () => { + const yamlConfigProvider = yamlWithDefaultTimeRange(); + yamlConfigProvider.timeRanges = [ + { + range: DEFAULT_TIME_RANGE, + comparisonTimeRanges: [ + { offset: TimeComparisonOption.WEEK }, + { offset: TimeComparisonOption.DAY }, + ], + }, + ]; + const manager = createTimeFilterManager({ yamlConfigProvider }); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect( + manager.comparisonTimeRangeOptions.map(({ name }) => name), + // The previous day is still dropped, since it is shorter than the range. + ).toEqual([TimeComparisonOption.WEEK]); + }); + + it("offers the custom comparison with allowCustomTimeRange", async () => { + const manager = createTimeFilterManager({ + config: { allowCustomTimeRange: true }, + }); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect( + manager.comparisonTimeRangeOptions.map(({ name }) => name), + ).toContain(TimeComparisonOption.CUSTOM); + }); +}); + +describe("scrub", () => { + it("records the range only once scrubbing ends", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const changes = recordChanges(manager); + const start = DateTime.fromISO("2024-03-26T00:00:00.000Z"); + const end = DateTime.fromISO("2024-03-27T00:00:00.000Z"); + + manager.onScrubRange({ start, end, isScrubbing: true }); + await flushStateChanges(); + + expect(manager.scrubInterval).toEqual({ start, end, isScrubbing: true }); + expect(manager.lastDefinedScrubInterval).toBeUndefined(); + expect(changes).toEqual([]); + + manager.onScrubRange({ start, end, isScrubbing: false }); + await flushStateChanges(); + + expect(isoOf(manager.lastDefinedScrubInterval?.start)).toBe( + "2024-03-26T00:00:00.000Z", + ); + expect(isoOf(manager.lastDefinedScrubInterval?.end)).toBe( + "2024-03-27T00:00:00.000Z", + ); + // The scrubbed range narrows the range queries use. + expect(manager.timeStart).toBe("2024-03-26T00:00:00.000Z"); + expect(manager.timeEnd).toBe("2024-03-27T00:00:00.000Z"); + expect(changes).toEqual([ + { + tr: DEFAULT_TIME_RANGE, + grain: "day", + highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z", + }, + ]); + }); + + it("orders a range scrubbed right to left", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + manager.onScrubRange({ + start: DateTime.fromISO("2024-03-27T00:00:00.000Z"), + end: DateTime.fromISO("2024-03-26T00:00:00.000Z"), + isScrubbing: false, + }); + + expect(manager.timeStart).toBe("2024-03-26T00:00:00.000Z"); + expect(manager.timeEnd).toBe("2024-03-27T00:00:00.000Z"); + }); + + it("resetScrubRange goes back to the selected range", async () => { + const manager = createTimeFilterManager(); + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + manager.onScrubRange({ + start: DateTime.fromISO("2024-03-26T00:00:00.000Z"), + end: DateTime.fromISO("2024-03-27T00:00:00.000Z"), + isScrubbing: false, + }); + + manager.resetScrubRange(); + + expect(manager.scrubInterval).toBeUndefined(); + expect(manager.lastDefinedScrubInterval).toBeUndefined(); + expect({ start: manager.timeStart, end: manager.timeEnd }).toEqual( + resolvedIntervalOf(DEFAULT_TIME_RANGE), + ); + }); +}); + +describe("inherit", () => { + function createParentAndChild() { + const parent = createTimeFilterManager(); + const child = createTimeFilterManager({ parent }); + return { parent, child }; + } + + it("resolves to the time range of the parent", async () => { + const { parent, child } = createParentAndChild(); + parent.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(parent, DEFAULT_TIME_RANGE); + + child.storeSync.setUrlParams( + new URLSearchParams({ tr: INHERIT_TIME_RANGE_ALIAS }), + ); + await waitForTimeRange(child, DEFAULT_TIME_RANGE); + + expect(child.urlTimeRange).toBe(INHERIT_TIME_RANGE_ALIAS); + expect(intervalOf(child)).toEqual(resolvedIntervalOf(DEFAULT_TIME_RANGE)); + // The url keeps the alias, so the child keeps following the parent. + expect(paramsOf(child).tr).toBe(INHERIT_TIME_RANGE_ALIAS); + }); + + it("follows the parent when its time range changes", async () => { + const { parent, child } = createParentAndChild(); + parent.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(parent, DEFAULT_TIME_RANGE); + child.storeSync.setUrlParams( + new URLSearchParams({ tr: INHERIT_TIME_RANGE_ALIAS }), + ); + await waitForTimeRange(child, DEFAULT_TIME_RANGE); + + await parent.onSelectRange("12h"); + await flushStateChanges(); + + await waitForTimeRange(child, "12h as of latest/h+1h"); + expect(child.urlTimeRange).toBe(INHERIT_TIME_RANGE_ALIAS); + }); + + it("follows the parent to a range with the same grain", async () => { + const { parent, child } = createParentAndChild(); + parent.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(parent, DEFAULT_TIME_RANGE); + child.storeSync.setUrlParams( + new URLSearchParams({ tr: INHERIT_TIME_RANGE_ALIAS }), + ); + await waitForTimeRange(child, DEFAULT_TIME_RANGE); + + await parent.onSelectRange("4W"); + await flushStateChanges(); + await asyncWait(20); + + expect(child.timeRange).toBe("4W as of latest/D+1D"); + }); + + it("stops following the parent once it has its own time range", async () => { + const { parent, child } = createParentAndChild(); + parent.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(parent, DEFAULT_TIME_RANGE); + child.storeSync.setUrlParams( + new URLSearchParams({ tr: "12h as of latest/h+1h" }), + ); + await waitForTimeRange(child, "12h as of latest/h+1h"); + + await parent.onSelectRange("4W"); + await flushStateChanges(); + await asyncWait(20); + + expect(child.timeRange).toBe("12h as of latest/h+1h"); + }); +}); + +describe("multiple metrics views", () => { + it("uses the interval that ends last", async () => { + const manager = createTimeFilterManager({ + provider: await createReadyMetricsViewsProvider([ + AD_BIDS_METRICS_NAME, + AD_BIDS_MIRROR_METRICS_NAME, + ]), + }); + + manager.setUrlParams(new URLSearchParams()); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + + expect(intervalOf(manager)).toEqual({ + start: "2024-03-26T00:00:00.000Z", + end: "2024-04-02T00:00:00.000Z", + }); + }); +}); + +describe("hasTimeSeries", () => { + it("is true when a metrics view has a time dimension", async () => { + const manager = createTimeFilterManager({ + provider: await createReadyMetricsViewsProvider([ + AD_BIDS_NO_TIME_METRICS_NAME, + AD_BIDS_METRICS_NAME, + ]), + }); + + expect(manager.hasTimeSeries).toBe(true); + }); + + it("is false when no metrics view has a time dimension", async () => { + // Metrics views without a time dimension have no summary to wait on, + // so the provider is ready as soon as the specs land. + const manager = createTimeFilterManager({ + provider: await createReadyMetricsViewsProvider([ + AD_BIDS_NO_TIME_METRICS_NAME, + ]), + }); + + expect(manager.hasTimeSeries).toBe(false); + }); +}); + +describe("getTimeControlStore", () => { + it("exposes the comparison range only while comparison is shown", async () => { + const manager = createTimeFilterManager(); + manager.storeSync.setUrlParams( + new URLSearchParams({ tr: DEFAULT_TIME_RANGE }), + ); + await waitForTimeRange(manager, DEFAULT_TIME_RANGE); + const store = manager.getTimeControlStore(); + + expect(get(store)).toMatchObject({ + timeRange: DEFAULT_TIME_RANGE, + timeGrain: V1TimeGrain.TIME_GRAIN_DAY, + timeZone: "UTC", + showComparison: false, + apiComparisonTimeRange: undefined, + hasTimeSeries: true, + ready: true, + }); + + manager.setShowComparison(true); + + expect(get(store)).toMatchObject({ + showComparison: true, + apiComparisonTimeRange: { + start: "2024-03-18T00:00:00.000Z", + end: "2024-03-25T00:00:00.000Z", + timeZone: "UTC", + }, + }); + }); +}); diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts index 864e6a3a3d23..1db7d3f1d22e 100644 --- a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts +++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts @@ -11,6 +11,7 @@ import { constructAsOfString, constructNewString, deriveInterval, + INHERIT_TIME_RANGE_ALIAS, } from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts"; import { allowedGrainsForInterval, @@ -50,11 +51,11 @@ import { getOrderedStartEndDateTime } from "@rilldata/web-common/features/dashbo import { toStore } from "svelte/store"; import { UrlParamsChangeTracker } from "@rilldata/web-common/lib/store-utils/url-search-params-store.svelte.ts"; import { EventEmitter } from "@rilldata/web-common/lib/event-emitter.ts"; +import type { TimeFiltersConfig } from "@rilldata/web-common/features/dashboards/time-controls/time-filters-config.ts"; type ComparisonTimeRangeOption = { name: TimeComparisonOption; key: number; - interval: Interval; }; const TimeFilterParams = new Set([ @@ -63,8 +64,14 @@ const TimeFilterParams = new Set([ ExploreStateURLParams.TimeZone, ExploreStateURLParams.TimeDimension, ExploreStateURLParams.ComparisonTimeRange, + ExploreStateURLParams.HighlightedTimeRange, ]); +export const DEFAULT_INHERIT_URL_PARAMS = [ + [ExploreStateURLParams.TimeRange, INHERIT_TIME_RANGE_ALIAS], + [ExploreStateURLParams.ComparisonTimeRange, INHERIT_TIME_RANGE_ALIAS], +]; + type ScrubRange = { start: DateTime; end: DateTime; @@ -94,10 +101,24 @@ type TimeFilterManagerEvents = { export class TimeFilterManager implements UrlParamsStore { // State set directly from URL/controls. + /** + * Exact value of time range as set from setUrlParams. It can have values like `inherit`. + */ + public urlTimeRange = $state(undefined); + /** + * Resolved time range, taking into account the parent time range when `inherit` is set. + */ public timeRange = $state(undefined); public timeGrain = $state(undefined); public timeZone = $state(DEFAULT_TIMEZONE); public timeDimension = $state(undefined); + /** + * Exact value of comparison time range as set from setUrlParams. It can have values like `inherit`. + */ + public urlComparisonTimeRange = $state(undefined); + /** + * Resolved comparison time range, taking into account the parent time range when `inherit` is set. + */ public comparisonTimeRange = $state(undefined); public showComparison = $state(false); public lastDefinedScrubInterval = $state | undefined>( @@ -139,11 +160,8 @@ export class TimeFilterManager implements UrlParamsStore { public aggregationOptions: V1TimeGrain[]; public comparisonTimeRangeOptions: ComparisonTimeRangeOption[]; - public curParams = $state(new URLSearchParams()); - public hasTimeSeries: boolean | undefined; public ready = $state(false); - public specLoaded: boolean; private events = new EventEmitter(); public readonly on = this.events.on.bind( @@ -153,25 +171,33 @@ export class TimeFilterManager implements UrlParamsStore { public paramKeys = TimeFilterParams; public readonly storeSync: UrlParamsChangeTracker; - // Temporary lock in explore. Once we move whereFilter out of explore, we can remove this. - public updating = false; - - private fetchingTimeRange = ""; - public constructor( private readonly runtimeClient: RuntimeClient, private readonly metricsViewsProvider: MetricsViewsProvider, private readonly yamlConfigProvider: YAMLConfigProvider, - // TODO: maybe this can be moved to yamlConfigProvider? - public readonly allowCustomTimeRange: boolean, - private readonly addDefault: boolean = false, - private readonly saveGrain: boolean = true, - private readonly log = false, + public readonly config: TimeFiltersConfig = {}, + public readonly parent?: TimeFilterManager, ) { + this.storeSync = new UrlParamsChangeTracker(this, config.log); + + this.ready = metricsViewsProvider.ready; metricsViewsProvider.on("time-specs-loaded", () => { this.ready = true; this.events.emit("ready"); }); + // Replay `inherit` time range when parent changed. + parent?.storeSync.on("change", () => { + const timeRangeIsInherited = + this.urlTimeRange === INHERIT_TIME_RANGE_ALIAS; + const comparisonTimeRangeIsInherited = + this.urlComparisonTimeRange === INHERIT_TIME_RANGE_ALIAS; + if (timeRangeIsInherited) { + // If comparison is inherited it will be reapplied from applyTimeRange + void this.applyTimeRange(this.urlTimeRange!, this.timeZone, false); + } else if (comparisonTimeRangeIsInherited) { + this.applyComparisonRange(this.urlComparisonTimeRange!); + } + }); this.minDate = $derived.by(() => { const minDate = this.metricsViewsProvider.timeRangeSummary?.min @@ -195,9 +221,9 @@ export class TimeFilterManager implements UrlParamsStore { return diff.milliseconds > 0; }); this.canPanRight = $derived.by(() => { - if (!this.minDate || !this.interval) return false; + if (!this.maxDate || !this.interval) return false; // max - selected end > 0 - const diff = this.minDate.diff(this.interval.end); + const diff = this.maxDate.diff(this.interval.end); return diff.milliseconds > 0; }); @@ -294,9 +320,10 @@ export class TimeFilterManager implements UrlParamsStore { } public setUrlParams(urlParams: URLSearchParams) { - if (this.log) console.log("setUrlParams", urlParams.toString()); + if (this.config.log) + console.log("TimeFilterManager::setUrlParams", urlParams.toString()); - if (this.saveGrain) { + if (!this.config.skipTimeGrain) { const urlGrain = urlParams.get(ExploreStateURLParams.TimeGrain); this.timeGrain = urlGrain ? DateTimeUnitToV1TimeGrain[urlGrain] @@ -317,6 +344,33 @@ export class TimeFilterManager implements UrlParamsStore { urlParams.get(ExploreStateURLParams.ComparisonTimeRange) ?? "rill-PP", ); + // Apply time range last so that params are taken from url + if (urlParams.has(ExploreStateURLParams.TimeRange)) { + void this.applyTimeRange( + urlParams.get(ExploreStateURLParams.TimeRange)!, + this.timeZone, + false, + ); + } else { + let defaultTimeRange = this.yamlConfigProvider.defaultTimeRange; + if (!defaultTimeRange) { + defaultTimeRange = getDefaultTimeRange( + this.metricsViewsProvider.largestMinTimeGrain, + this.metricsViewsProvider.timeRangeSummary, + ); + } + if (defaultTimeRange && !this.config.skipDefaultTimeRange) { + void this.applyTimeRange(defaultTimeRange, this.timeZone, false); + } else { + this.urlTimeRange = undefined; + this.timeRange = undefined; + this.interval = undefined; + } + } + + // Apply the highlighted range after the time range, since applying a time range clears it. + this.scrubInterval = undefined; + this.lastDefinedScrubInterval = undefined; if (urlParams.has(ExploreStateURLParams.HighlightedTimeRange)) { try { const parsedHighlightRange = parseRillTime( @@ -339,40 +393,16 @@ export class TimeFilterManager implements UrlParamsStore { // no-op } } - - // Apply time range last so that params are taken from url - if (urlParams.has(ExploreStateURLParams.TimeRange)) { - void this.applyTimeRange( - urlParams.get(ExploreStateURLParams.TimeRange)!, - this.timeZone, - false, - ); - } else { - let defaultTimeRange = this.yamlConfigProvider.defaultTimeRange; - if (!defaultTimeRange) { - defaultTimeRange = getDefaultTimeRange( - this.metricsViewsProvider.largestMinTimeGrain, - this.metricsViewsProvider.timeRangeSummary, - ); - } - if (defaultTimeRange && this.addDefault) { - void this.applyTimeRange(defaultTimeRange, this.timeZone, false); - } else { - this.timeRange = undefined; - this.fetchingTimeRange = ""; - this.interval = undefined; - } - } } public applyFilterToParams(urlParams: URLSearchParams) { - if (this.timeRange) { - urlParams.set(ExploreStateURLParams.TimeRange, this.timeRange); + if (this.urlTimeRange) { + urlParams.set(ExploreStateURLParams.TimeRange, this.urlTimeRange); } else { urlParams.delete(ExploreStateURLParams.TimeRange); } - if (this.saveGrain) { + if (!this.config.skipTimeGrain) { const mappedGrain = this.timeGrain ? V1TimeGrainToDateTimeUnit[this.timeGrain] : undefined; @@ -395,10 +425,10 @@ export class TimeFilterManager implements UrlParamsStore { urlParams.delete(ExploreStateURLParams.TimeDimension); } - if (this.showComparison && this.comparisonTimeRange) { + if (this.showComparison && this.urlComparisonTimeRange) { urlParams.set( ExploreStateURLParams.ComparisonTimeRange, - this.comparisonTimeRange, + this.urlComparisonTimeRange, ); } else { urlParams.delete(ExploreStateURLParams.ComparisonTimeRange); @@ -423,6 +453,10 @@ export class TimeFilterManager implements UrlParamsStore { // Mutation methods used by different UI controls public onSelectRange(range: string, ignoreSnap?: boolean) { + if (range === INHERIT_TIME_RANGE_ALIAS) { + return this.applyTimeRange(range, this.timeZone); + } + try { const parsed = parseRillTime(range); @@ -568,21 +602,10 @@ export class TimeFilterManager implements UrlParamsStore { this.storeSync.stateChanged(); } - public resetScrubRange() { + public resetScrubRange(notify = true) { this.lastDefinedScrubInterval = undefined; this.scrubInterval = undefined; - this.storeSync.stateChanged(); - } - - public createLocalFilterStore(metricsViewName: string) { - return new TimeFilterManager( - this.runtimeClient, - new MetricsViewsProvider(this.metricsViewsProvider.runtimeClient, [ - metricsViewName, - ]), - this.yamlConfigProvider, - this.allowCustomTimeRange, - ); + if (notify) this.storeSync.stateChanged(); } public getTimeControlStore() { @@ -613,26 +636,19 @@ export class TimeFilterManager implements UrlParamsStore { tz: string, notifyChange = true, ) { + this.urlTimeRange = newTimeRange; + if (newTimeRange === INHERIT_TIME_RANGE_ALIAS) { + if (!this.parent?.timeRange) return; + newTimeRange = this.parent.timeRange; + } + measureSelection.clear(); - this.resetScrubRange(); + this.resetScrubRange(false); // The runtime resolves the range against the metrics views, so their names are all this needs. // The time range summary can still be loading at this point; // waiting for it here would drop the range the dashboard loaded with. - if ( - !this.metricsViewsProvider.metricsViewNames.length || - this.fetchingTimeRange === newTimeRange - ) { - if (this.log) - console.log( - "applyTimeRange::earlyReturn", - !this.metricsViewsProvider.metricsViewNames.length, - this.fetchingTimeRange === newTimeRange, - ); - if (notifyChange) this.storeSync.stateChanged(); - return; - } - this.fetchingTimeRange = newTimeRange; + if (!this.metricsViewsProvider.metricsViewNames.length) return; // This should be returned by the API, but it is not yet implemented const includesTimeZoneOffset = newTimeRange.includes("tz"); @@ -701,14 +717,23 @@ export class TimeFilterManager implements UrlParamsStore { this.timeGrain = finalGrain; // Recalc comparison time range internal. - if (this.comparisonTimeRange) - this.applyComparisonRange(this.comparisonTimeRange); + if (this.urlComparisonTimeRange) + this.applyComparisonRange(this.urlComparisonTimeRange); - if (this.log) console.log("applyTimeRange::dataUpdated", newTimeRange); + if (this.config.log) + console.log( + "TimeFilterManager::applyTimeRange::dataUpdated", + newTimeRange, + ); if (notifyChange) this.storeSync.stateChanged(); } private applyComparisonRange(newComparisonTimeRange: string) { + this.urlComparisonTimeRange = newComparisonTimeRange; + if (newComparisonTimeRange === INHERIT_TIME_RANGE_ALIAS) { + if (!this.parent?.timeRange) return; + newComparisonTimeRange = this.parent.timeRange; + } this.comparisonTimeRange = newComparisonTimeRange; if (!this.timeRange) return; @@ -766,11 +791,11 @@ export class TimeFilterManager implements UrlParamsStore { timeRange.comparisonTimeRanges?.map( (co) => co.offset as TimeComparisonOption, ) ?? []; - if (this.allowCustomTimeRange) + if (this.config.allowCustomTimeRange) allOptions.push(TimeComparisonOption.CUSTOM); } else { allOptions = [...Object.values(TimeComparisonOption)]; - if (!this.allowCustomTimeRange) { + if (!this.config.allowCustomTimeRange) { allOptions = allOptions.filter( (o) => o !== TimeComparisonOption.CUSTOM, ); @@ -786,7 +811,7 @@ export class TimeFilterManager implements UrlParamsStore { this.timeZone, ); - return timeComparisonOptions + const filteredOptions = timeComparisonOptions .map((co, i) => { const comparisonTimeRange = getComparisonInterval( this.interval, @@ -802,9 +827,15 @@ export class TimeFilterManager implements UrlParamsStore { return { name: co, key: i, - interval: comparisonTimeRange, }; }) .filter(Boolean) as ComparisonTimeRangeOption[]; + if (this.config.allowCustomTimeRange) { + filteredOptions.push({ + name: TimeComparisonOption.CUSTOM, + key: filteredOptions.length, + }); + } + return filteredOptions; } } diff --git a/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte index 714940c51e43..dd3482189bc7 100644 --- a/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte +++ b/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte @@ -19,6 +19,8 @@ import type { Interval } from "luxon"; import type { TimeFiltersConfig } from "@rilldata/web-common/features/dashboards/time-controls/time-filters-config.ts"; import type { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; + import InheritTimeRangeOption from "@rilldata/web-common/features/dashboards/time-controls/super-pill/new-time-dropdown/InheritTimeRangeOption.svelte"; + import { INHERIT_TIME_RANGE_ALIAS } from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts"; let { timeFilterManager, @@ -32,6 +34,7 @@ let { showFullRange = true, allowCustomTimeRange = true, + showInheritRange = false, side = "bottom", } = $derived(config); @@ -41,10 +44,13 @@ timeZone, minDate, maxDate, + urlComparisonTimeRange, comparisonTimeRange, showComparison, comparisonTimeRangeOptions, comparisonInterval, + + parent, } = $derived(timeFilterManager); let { largestMinTimeGrain } = $derived(metricsViewsProvider); @@ -63,6 +69,16 @@ comparisonTimeRange ?? firstOption?.name ?? m.time_custom_range(), ); + let inheritedComparisonTimeRange = $derived( + urlComparisonTimeRange === INHERIT_TIME_RANGE_ALIAS, + ); + let inheritedComparisonDesc = $derived( + parent?.showComparison + ? (TIME_COMPARISON[parent?.comparisonTimeRange as TimeComparisonOption] + ?.label ?? m.time_custom_range()) + : m.canvas_comparison_off(), + ); + function applyCustomRange(range: Interval) { onSelectComparisonRange(`${range.start.toISO()} to ${range.end.toISO()}`); } @@ -117,7 +133,12 @@ class="rounded-r-full" >
- {#if !comparisonTimeRangeOptions.length && !showComparison} + {#if inheritedComparisonTimeRange && showInheritRange} + {m.canvas_inherit_from_canvas()} + + ยท {inheritedComparisonDesc} + + {:else if !comparisonTimeRangeOptions.length && !showComparison}

{m.time_no_comparison_period()}

{:else} {label} @@ -140,6 +161,13 @@
+ {#if showInheritRange} + + {/if} + {#each comparisonTimeRangeOptions as option (option.name)} {@const preset = TIME_COMPARISON[option.name]} {@const selected = selectedLabel === option.name} diff --git a/web-common/src/features/dashboards/time-controls/new-time-controls.ts b/web-common/src/features/dashboards/time-controls/new-time-controls.ts index 291b8169f6ee..fb75b04b9e06 100644 --- a/web-common/src/features/dashboards/time-controls/new-time-controls.ts +++ b/web-common/src/features/dashboards/time-controls/new-time-controls.ts @@ -143,6 +143,8 @@ export type AllTime = typeof ALL_TIME_RANGE_ALIAS; export type CustomRange = typeof CUSTOM_TIME_RANGE_ALIAS; export type ISODurationString = string; +export const INHERIT_TIME_RANGE_ALIAS = "inherit"; + // An extra entry at the top of a range or comparison menu that hands the choice back to a parent, // such as a canvas widget following the canvas time range. export type InheritRangeOption = { @@ -805,6 +807,9 @@ export function constructNewString({ snapToEnd: boolean; ref: RillTimeLabel | string | undefined; }): string { + // Safeguard to ensure we don't try to parse an `inherit` time range. + if (currentString === INHERIT_TIME_RANGE_ALIAS) return currentString; + const legacy = isUsingLegacyTime(currentString); const rillTime = parseRillTime( diff --git a/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte index f723dc7e3be7..d23444caa970 100644 --- a/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte +++ b/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte @@ -9,6 +9,7 @@ ALL_TIME_RANGE_ALIAS, bucketYamlRanges, getRangeLabel, + INHERIT_TIME_RANGE_ALIAS, RILL_TO_LABEL, } from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts"; import { getAbbreviationForIANA } from "@rilldata/web-common/lib/time/timezone"; @@ -36,6 +37,7 @@ import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts"; import { getTimeDimensionOptions } from "@rilldata/web-common/features/dashboards/time-controls/time-range-utils.ts"; import type { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts"; + import InheritTimeRangeOption from "@rilldata/web-common/features/dashboards/time-controls/super-pill/new-time-dropdown/InheritTimeRangeOption.svelte"; let { timeFilterManager, @@ -55,9 +57,11 @@ lockTimeZone = false, showFullRange = true, showWatermark = false, + showInheritRange = false, } = $derived(config); let { + urlTimeRange, timeRange: timeString, timeGrain, timeZone, @@ -117,10 +121,13 @@ ); let dateTimeAnchor = $derived(returnAnchor(ref, timeZone)); + let inheritedTimeRange = $derived(urlTimeRange === INHERIT_TIME_RANGE_ALIAS); + let usingLegacyTime = $derived(parsedTime?.isOldFormat); let hideTruncationSelector = $derived( parsedTime?.interval instanceof RillIsoInterval || - parsedTime?.interval instanceof RillAllTimeInterval, + parsedTime?.interval instanceof RillAllTimeInterval || + inheritedTimeRange, ); let timeColumn = $derived(timeDimension || primaryTimeDimension); @@ -189,6 +196,11 @@ aria-label={m.dashboard_select_time_range()} type="button" > + {#if inheritedTimeRange && showInheritRange} + + {m.canvas_inherit_from_canvas()} + + {/if} {#if timeString} {#if selectedLabel?.startsWith("-") || !isNaN(Number(selectedLabel?.[0]))} @@ -255,6 +267,13 @@ class="flex flex-col w-56 overflow-y-auto overflow-x-hidden flex-none py-1" >
+ {#if showInheritRange} + + {/if} + + import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; + import { INHERIT_TIME_RANGE_ALIAS } from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts"; + + let { + inherited, + onSelect, + }: { + inherited: boolean; + onSelect: (range: string) => void; + } = $props(); + + +
+ +
+
diff --git a/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts b/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts index e369fad9a248..8e2e3f43bfbb 100644 --- a/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts +++ b/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts @@ -116,7 +116,7 @@ export function useCanvasTimeFiltersVariant( return { initialUrlSearch: PageURLForInitialState, // The default preset lands in the url as soon as the dashboard loads. - initialUrlSearchHistory: [PageURLForDefaultPreset, PageURLForInitialState], + initialUrlSearchHistory: [PageURLForDefaultPreset], urlSearchWithTimeParams: (timeParams: Record) => { const urlSearch = new URLSearchParams(PageURLForInitialState); diff --git a/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts b/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts index c81f29bb8d00..03a4e7b6e546 100644 --- a/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts +++ b/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts @@ -162,8 +162,8 @@ function testTimeRange(timeRange: DashboardTimeControls | undefined) { if (!timeRange?.name) return undefined; return { expression: timeRange.name, - start: timeRange.start.toISOString(), - end: timeRange.end.toISOString(), + start: timeRange.start?.toISOString(), + end: timeRange.end?.toISOString(), ...("interval" in timeRange ? { grain: timeRange.interval } : {}), }; } diff --git a/web-common/src/features/dashboards/time-controls/time-filters-config.ts b/web-common/src/features/dashboards/time-controls/time-filters-config.ts index c1aa7867b556..3a9e4b854418 100644 --- a/web-common/src/features/dashboards/time-controls/time-filters-config.ts +++ b/web-common/src/features/dashboards/time-controls/time-filters-config.ts @@ -1,12 +1,16 @@ export type TimeFiltersConfig = { hidePan?: boolean; - showGrainSelector?: boolean; // TODO: not used in new picker? showTimeDimensionSelector?: boolean; showComparisonSelector?: boolean; allowCustomTimeRange?: boolean; + skipDefaultTimeRange?: boolean; + skipTimeGrain?: boolean; lockTimeZone?: boolean; showFullRange?: boolean; showWatermark?: boolean; + showInheritRange?: boolean; + + log?: boolean; side?: "top" | "right" | "bottom" | "left"; }; diff --git a/web-common/src/features/dashboards/time-series/utils.ts b/web-common/src/features/dashboards/time-series/utils.ts index d3adecc2679c..b27b6130260b 100644 --- a/web-common/src/features/dashboards/time-series/utils.ts +++ b/web-common/src/features/dashboards/time-series/utils.ts @@ -116,8 +116,8 @@ export function getOrderedStartEnd(start: Date, stop: Date) { // Return start and end of the time range that is ordered. export function getOrderedStartEndDateTime(start: DateTime, stop: DateTime) { - const startMs = start?.get("millisecond"); - const stopMs = stop?.get("millisecond"); + const startMs = start?.toMillis(); + const stopMs = stop?.toMillis(); if (startMs > stopMs) { return { start: stop, end: start }; diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts index 23b9d59f1f0c..a50694985cb1 100644 --- a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts +++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts @@ -130,10 +130,7 @@ export class MetricsViewsProvider { public readonly runtimeClient: RuntimeClient, initMetricsViewNames: string[], ) { - this.metricsViewNames = initMetricsViewNames.filter(Boolean); - this.pendingSpecs = new Set(initMetricsViewNames); - this.pendingTimestamps = new Set(initMetricsViewNames); - this.events.emit("update-metrics-views", this.metricsViewNames); + this.setMetricsViewNames(initMetricsViewNames); const allResourcesQuery = createRuntimeServiceListResources( runtimeClient, diff --git a/web-common/src/features/scheduled-reports/utils.ts b/web-common/src/features/scheduled-reports/utils.ts index d276b3ade7d6..10150d9055e2 100644 --- a/web-common/src/features/scheduled-reports/utils.ts +++ b/web-common/src/features/scheduled-reports/utils.ts @@ -193,7 +193,6 @@ export function getFiltersAndTimeControlsFromAggregationRequest( client, metricsViewProvider, yamlConfigProvider, - false, ); const timeFilterParams = new URLSearchParams(); diff --git a/web-common/src/lib/store-utils/url-search-params-store.svelte.ts b/web-common/src/lib/store-utils/url-search-params-store.svelte.ts index f9af9643f8d0..7a19a40ece5e 100644 --- a/web-common/src/lib/store-utils/url-search-params-store.svelte.ts +++ b/web-common/src/lib/store-utils/url-search-params-store.svelte.ts @@ -3,12 +3,18 @@ import { copySubsetParams } from "@rilldata/web-common/lib/url-utils.ts"; import type { UrlParamsStore } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts"; import { page } from "$app/state"; import { goto } from "$app/navigation"; +import { expandCompressedParams } from "@rilldata/web-common/features/dashboards/url-state/compression.ts"; type UrlParamsChangeTrackerEvents = { /** - * Fired when the underlying class's state changes. + * Fired when the underlying class's state changes internally. + * Happens when UI controls directly update class's state. * Does not fire when `setUrlParams` is called to avoid loop. */ + "internal-change": URLSearchParams; + /** + * Fired when + */ change: URLSearchParams; }; @@ -22,7 +28,7 @@ export class UrlParamsChangeTracker { /** * Source of truth for the underlying class's state. */ - public searchParams = $state(new URLSearchParams()); + public searchParams = $state(); private pendingParams: URLSearchParams | undefined = undefined; @@ -31,7 +37,10 @@ export class UrlParamsChangeTracker { this.events, ) as typeof this.events.on; - public constructor(private readonly store: UrlParamsStore) { + public constructor( + private readonly store: UrlParamsStore, + private readonly log = false, + ) { this.store.on("ready", () => this.replayPendingParams()); } @@ -41,15 +50,38 @@ export class UrlParamsChangeTracker { */ public setUrlParams(urlParams: URLSearchParams) { if (!this.store.ready) { + if (this.log) + console.log( + "UrlParamsChangeTracker::setUrlParams::delay", + urlParams.toString(), + ); this.pendingParams = urlParams; return; } - const relevantParams = copySubsetParams(urlParams, this.store.paramKeys); - if (this.searchParams.toString() === relevantParams.toString()) return; + let expandedUrlParams: URLSearchParams; + try { + expandedUrlParams = expandCompressedParams(urlParams); + } catch { + // If we fail to decompress, do not throw here. + return; + } + + const relevantParams = copySubsetParams( + expandedUrlParams, + this.store.paramKeys, + ); + if (this.log) + console.log( + "UrlParamsChangeTracker::setUrlParams", + this.searchParams?.toString() === relevantParams.toString(), + relevantParams.toString(), + ); + if (this.searchParams?.toString() === relevantParams.toString()) return; this.searchParams = this.store.normalizeParams(relevantParams); this.store.setUrlParams(relevantParams); + this.events.emit("change", this.searchParams); } /** @@ -67,7 +99,7 @@ export class UrlParamsChangeTracker { * @param emptySearchOverride The search override to use if the store's state is empty. */ public syncToUrl(emptySearchOverride = "") { - return this.on("change", (newUrlParams) => { + return this.on("internal-change", (newUrlParams) => { const urlParamsToApply = new URLSearchParams(page.url.searchParams); this.store.paramKeys.forEach((key) => { if (newUrlParams.has(key)) { @@ -97,8 +129,9 @@ export class UrlParamsChangeTracker { const urlParams = new URLSearchParams(); this.store.applyFilterToParams(urlParams); - if (this.searchParams.toString() === urlParams.toString()) return; + if (this.searchParams?.toString() === urlParams.toString()) return; this.searchParams = urlParams; + this.events.emit("internal-change", this.searchParams); this.events.emit("change", this.searchParams); } }