diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index e94bc3378..bdd2a780d 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -41,6 +41,8 @@ export { SceneTimeZoneOverride } from './core/SceneTimeZoneOverride'; export { SceneQueryRunner, type QueryRunnerState } from './querying/SceneQueryRunner'; export { DataProviderProxy } from './querying/DataProviderProxy'; +export { buildApplicabilityMatcher } from './variables/applicabilityUtils'; +export { findClosestAdHocFilterInHierarchy } from './variables/adhoc/patchGetAdhocFilters'; export { type ExtraQueryDescriptor, type ExtraQueryProvider, diff --git a/packages/scenes/src/variables/DrilldownDependenciesManager.test.ts b/packages/scenes/src/variables/DrilldownDependenciesManager.test.ts new file mode 100644 index 000000000..8d9e50573 --- /dev/null +++ b/packages/scenes/src/variables/DrilldownDependenciesManager.test.ts @@ -0,0 +1,105 @@ +import { AdHocFiltersVariable, AdHocFilterWithLabels } from './adhoc/AdHocFiltersVariable'; +import { DrilldownDependenciesManager } from './DrilldownDependenciesManager'; +import { GroupByVariable } from './groupby/GroupByVariable'; +import { VariableDependencyConfig } from './VariableDependencyConfig'; + +function createManager(opts: { adhocVar?: AdHocFiltersVariable; groupByVar?: GroupByVariable }) { + const mockDependencyConfig = { setVariableNames: jest.fn() } as unknown as VariableDependencyConfig; + const manager = new DrilldownDependenciesManager(mockDependencyConfig); + + if (opts.adhocVar) { + manager['_adhocFiltersVar'] = opts.adhocVar; + } + if (opts.groupByVar) { + manager['_groupByVar'] = opts.groupByVar; + } + + return manager; +} + +function createAdhocVar( + filters: AdHocFilterWithLabels[], + originFilters?: AdHocFilterWithLabels[], + applicabilityEnabled?: boolean +) { + return new AdHocFiltersVariable({ + datasource: { uid: 'my-ds-uid' }, + name: 'filters', + filters, + originFilters, + applicabilityEnabled, + }); +} + +function createGroupByVar(value: string[], keysApplicability?: any[], applicabilityEnabled?: boolean) { + return new GroupByVariable({ + datasource: { uid: 'my-ds-uid' }, + name: 'groupby', + key: 'testGroupBy', + value, + text: value, + keysApplicability, + applicabilityEnabled, + }); +} + +describe('DrilldownDependenciesManager', () => { + describe('getFilters', () => { + it('should return undefined when no adhocFiltersVar', () => { + const manager = createManager({}); + expect(manager.getFilters()).toBeUndefined(); + }); + + it('should return all complete filters when no applicability results', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'env', value: 'prod', operator: '=' }, + { key: 'cluster', value: 'us', operator: '=' }, + ]; + + const manager = createManager({ adhocVar: createAdhocVar(filters) }); + + const result = manager.getFilters() ?? []; + expect(result).toHaveLength(2); + expect(result[0].key).toBe('env'); + expect(result[1].key).toBe('cluster'); + }); + + it('should exclude incomplete filters', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'env', value: 'prod', operator: '=' }, + { key: '', value: '', operator: '' }, + ]; + + const manager = createManager({ adhocVar: createAdhocVar(filters) }); + + const result = manager.getFilters() ?? []; + expect(result).toHaveLength(1); + expect(result[0].key).toBe('env'); + }); + + it('should exclude variable-level nonApplicable filters', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'env', value: 'prod', operator: '=' }, + { key: 'pod', value: 'abc', operator: '=', nonApplicable: true }, + ]; + + const manager = createManager({ adhocVar: createAdhocVar(filters) }); + + const result = manager.getFilters() ?? []; + expect(result).toHaveLength(1); + expect(result[0].key).toBe('env'); + }); + }); + + describe('getGroupByKeys', () => { + it('should return undefined when no groupByVar', () => { + const manager = createManager({}); + expect(manager.getGroupByKeys()).toBeUndefined(); + }); + + it('should return all applicable keys', () => { + const manager = createManager({ groupByVar: createGroupByVar(['ns', 'pod']) }); + expect(manager.getGroupByKeys()).toEqual(['ns', 'pod']); + }); + }); +}); diff --git a/packages/scenes/src/variables/DrilldownDependenciesManager.ts b/packages/scenes/src/variables/DrilldownDependenciesManager.ts index 4c904e786..200e12adc 100644 --- a/packages/scenes/src/variables/DrilldownDependenciesManager.ts +++ b/packages/scenes/src/variables/DrilldownDependenciesManager.ts @@ -81,15 +81,21 @@ export class DrilldownDependenciesManager { } public getFilters(): AdHocFilterWithLabels[] | undefined { - return this._adhocFiltersVar - ? [...(this._adhocFiltersVar.state.originFilters ?? []), ...this._adhocFiltersVar.state.filters].filter( - (f) => isFilterComplete(f) && isFilterApplicable(f) - ) - : undefined; + if (!this._adhocFiltersVar) { + return undefined; + } + + const stateFilters = this._adhocFiltersVar.state.filters; + const originFilters = this._adhocFiltersVar.state.originFilters ?? []; + const allFilters = [...originFilters, ...stateFilters]; + return allFilters.filter((f) => isFilterComplete(f) && isFilterApplicable(f)); } public getGroupByKeys(): string[] | undefined { - return this._groupByVar ? this._groupByVar.getApplicableKeys() : undefined; + if (!this._groupByVar) { + return undefined; + } + return this._groupByVar.getApplicableKeys(); } public cleanup(): void { diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.test.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.test.tsx index 77ed1aa2e..a20ed69bb 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.test.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.test.tsx @@ -1,7 +1,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { DataSourceSrv, locationService, setDataSourceSrv, setRunRequest, setTemplateSrv } from '@grafana/runtime'; -import { DataQueryRequest, DataSourceApi, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; +import { + DataQueryRequest, + DataSourceApi, + getDefaultTimeRange, + LoadingState, + PanelData, + // @ts-expect-error (temporary till we update grafana/data) + DEFAULT_APPLICABILITY_KEY, +} from '@grafana/data'; import { Observable, of } from 'rxjs'; import { EmbeddedScene } from '../../components/EmbeddedScene'; import { SceneFlexLayout, SceneFlexItem } from '../../components/layout/SceneFlexLayout'; @@ -234,7 +242,7 @@ let runRequestSet = false; function setup(overrides?: Partial) { const getTagKeysSpy = jest.fn(); const getTagValuesSpy = jest.fn(); - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([]); + const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue(new Map([[DEFAULT_APPLICABILITY_KEY, []]])); const getRecommendedDrilldownsSpy = jest.fn().mockResolvedValue({ filters: [] }); setDataSourceSrv({ diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx index c2cf60146..ca845c976 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersRecommendations.tsx @@ -1,21 +1,18 @@ import React from 'react'; -import { - // @ts-expect-error (temporary till we update grafana/data) - DrilldownsApplicability, - store, -} from '@grafana/data'; +import { store } from '@grafana/data'; import { sceneGraph } from '../../core/sceneGraph'; import { getEnrichedDataRequest } from '../../querying/getEnrichedDataRequest'; import { getQueriesForVariables } from '../utils'; -import { getDataSource } from '../../utils/getDataSource'; + import { DrilldownRecommendations, DrilldownPill } from '../components/DrilldownRecommendations'; import { ScopesVariable } from '../variants/ScopesVariable'; import { SCOPES_VARIABLE_NAME } from '../constants'; import { AdHocFilterWithLabels, AdHocFiltersVariable } from './AdHocFiltersVariable'; import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneComponentProps, SceneObjectState } from '../../core/types'; -import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject'; + import { Unsubscribable } from 'rxjs'; +import { buildApplicabilityMatcher } from '../applicabilityUtils'; export const MAX_RECENT_DRILLDOWNS = 3; export const MAX_STORED_RECENT_DRILLDOWNS = 10; @@ -45,10 +42,6 @@ export class AdHocFiltersRecommendations extends SceneObjectBase { const json = store.get(this._getStorageKey()); const storedFilters = json ? JSON.parse(json) : []; @@ -110,7 +103,7 @@ export class AdHocFiltersRecommendations extends SceneObjectBase(); - response.forEach((item: DrilldownsApplicability) => { - applicabilityMap.set(item.key, item.applicable !== false); - }); + const matcher = buildApplicabilityMatcher(response); const applicableFilters = storedFilters .filter((f) => { - const isApplicable = applicabilityMap.get(f.key); - return isApplicable === undefined || isApplicable === true; + const result = matcher(f.key, f.origin); + return !result || result.applicable; }) .slice(-MAX_RECENT_DRILLDOWNS); diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.test.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.test.tsx index aff9abdbe..827b25af0 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.test.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.test.tsx @@ -26,6 +26,8 @@ import { PanelData, Scope, ScopeSpecFilter, + // @ts-expect-error (temporary till we update grafana/data) + DEFAULT_APPLICABILITY_KEY, } from '@grafana/data'; import { Observable, of } from 'rxjs'; import userEvent from '@testing-library/user-event'; @@ -2354,6 +2356,7 @@ describe.each(['11.1.2', '11.1.1'])('AdHocFiltersVariable', (v) => { //pod and static are non-applicable const { filtersVar, getDrilldownsApplicabilitySpy } = setup( { + applicabilityEnabled: true, filters: [ { key: 'cluster', @@ -2400,6 +2403,7 @@ describe.each(['11.1.2', '11.1.1'])('AdHocFiltersVariable', (v) => { //pod and static are non-applicable const { filtersVar, getDrilldownsApplicabilitySpy, getTagKeysSpy } = setup( { + applicabilityEnabled: true, filters: [ { key: 'cluster', @@ -2462,6 +2466,7 @@ describe.each(['11.1.2', '11.1.1'])('AdHocFiltersVariable', (v) => { //pod and static are non-applicable const { filtersVar, getDrilldownsApplicabilitySpy } = setup( { + applicabilityEnabled: true, filters: [ { key: 'cluster', @@ -2685,6 +2690,7 @@ describe.each(['11.1.2', '11.1.1'])('AdHocFiltersVariable', (v) => { it('should set non-applicable filters on activation', async () => { setup( { + applicabilityEnabled: true, filters: [ { key: 'pod', operator: '=', value: 'val1' }, { key: 'container', operator: '=', value: 'val3' }, @@ -3316,12 +3322,14 @@ function setup( ...(useGetDrilldownsApplicability && { getDrilldownsApplicability(options: any) { getDrilldownsApplicabilitySpy(options); - return [ - { key: 'cluster', applicable: true }, - { key: 'container', applicable: true }, - { key: 'pod', applicable: false, reason: 'reason' }, - { key: 'static', applicable: false, origin: 'dashboard' }, - ]; + const nonApplicableKeys = new Set(['pod', 'static']); + const results = (options.filters ?? []).map((f: any) => ({ + key: f.key, + applicable: !nonApplicableKeys.has(f.key), + ...(nonApplicableKeys.has(f.key) && { reason: 'reason' }), + ...(f.origin && { origin: f.origin }), + })); + return new Map([[DEFAULT_APPLICABILITY_KEY, results]]); }, }), }; diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx index 020417792..27782dd89 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx @@ -6,6 +6,8 @@ import { MetricFindValue, // @ts-expect-error (temporary till we update grafana/data) DrilldownsApplicability, + // @ts-expect-error (temporary till we update grafana/data) + DEFAULT_APPLICABILITY_KEY, Scope, SelectableValue, } from '@grafana/data'; @@ -32,6 +34,7 @@ import { getEnrichedFiltersRequest } from '../getEnrichedFiltersRequest'; import { AdHocFiltersComboboxRenderer } from './AdHocFiltersCombobox/AdHocFiltersComboboxRenderer'; import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject'; import { debounce, isEqual } from 'lodash'; +import { buildApplicabilityMatcher } from '../applicabilityUtils'; import { getAdHocFiltersFromScopes } from './getAdHocFiltersFromScopes'; import { VariableDependencyConfig } from '../VariableDependencyConfig'; import { getQueryController } from '../../core/sceneGraph/getQueryController'; @@ -322,8 +325,6 @@ export class AdHocFiltersVariable this.restoreOriginalFilter(filter); } }); - - this.setState({ applicabilityEnabled: false }); }; }; @@ -498,6 +499,8 @@ export class AdHocFiltersVariable if ((filterExpressionChanged && options?.skipPublish !== true) || options?.forcePublish) { this.publishEvent(new SceneVariableValueChangedEvent(this), true); } + + this._debouncedVerifyApplicability(); } public restoreOriginalFilter(filter: AdHocFilterWithLabels) { @@ -586,6 +589,7 @@ export class AdHocFiltersVariable return f === filter ? { ...f, ...update } : f; }) ?? []; this.setState({ originFilters: updatedFilters }); + this._debouncedVerifyApplicability(); return; } @@ -609,6 +613,7 @@ export class AdHocFiltersVariable }); this.setState({ filters: updatedFilters }); + this._debouncedVerifyApplicability(); this._recommendations?.storeRecentFilter({ ...filter, @@ -712,11 +717,15 @@ export class AdHocFiltersVariable } } + public async getResolvedDataSource() { + return this._dataSourceSrv.get(this.state.datasource, this._scopedVars); + } + public async getFiltersApplicabilityForQueries( filters: AdHocFilterWithLabels[], queries: SceneDataQuery[] ): Promise { - const ds = await this._dataSourceSrv.get(this.state.datasource, this._scopedVars); + const ds = await this.getResolvedDataSource(); // @ts-expect-error (temporary till we update grafana/data) if (!ds || !ds.getDrilldownsApplicability) { return; @@ -725,17 +734,23 @@ export class AdHocFiltersVariable const timeRange = sceneGraph.getTimeRange(this).state.value; // @ts-expect-error (temporary till we update grafana/data) - return await ds.getDrilldownsApplicability({ + const result: Map = await ds.getDrilldownsApplicability({ filters, queries, timeRange, scopes: sceneGraph.getScopes(this), ...getEnrichedFiltersRequest(this), }); + + return result?.get(DEFAULT_APPLICABILITY_KEY); } public async _verifyApplicability() { - const filters = [...this.state.filters, ...(this.state.originFilters ?? [])]; + if (!this.state.applicabilityEnabled) { + return; + } + + const filters = [...(this.state.originFilters ?? []), ...this.state.filters]; const queries = this.state.useQueriesAsFilterForOptions ? getQueriesForVariables(this) : undefined; const response = await this.getFiltersApplicabilityForQueries(filters, queries ?? []); @@ -744,10 +759,7 @@ export class AdHocFiltersVariable return; } - const responseMap = new Map(); - response.forEach((filter: DrilldownsApplicability) => { - responseMap.set(`${filter.key}${filter.origin ? `-${filter.origin}` : ''}`, filter); - }); + const matchResult = buildApplicabilityMatcher(response); const update = { applicabilityEnabled: true, @@ -755,32 +767,30 @@ export class AdHocFiltersVariable originFilters: [...(this.state.originFilters ?? [])], }; - update.filters.forEach((f) => { - const filter = responseMap.get(f.key); - - if (filter) { - f.nonApplicable = !filter.applicable; - f.nonApplicableReason = filter.reason; - } - }); - - update.originFilters?.forEach((f) => { - const filter = responseMap.get(`${f.key}-${f.origin}`); - - if (filter) { + update.originFilters?.forEach((f, i) => { + const result = matchResult(f.key, f.origin, i); + if (result) { if (!f.matchAllFilter) { - f.nonApplicable = !filter.applicable; - f.nonApplicableReason = filter.reason; + f.nonApplicable = !result.applicable; + f.nonApplicableReason = result.reason; } const originalValue = this._originalValues.get(`${f.key}-${f.origin}`); if (originalValue) { - originalValue.nonApplicable = !filter.applicable; - originalValue.nonApplicableReason = filter?.reason; + originalValue.nonApplicable = !result.applicable; + originalValue.nonApplicableReason = result?.reason; } } }); + update.filters.forEach((f, i) => { + const result = matchResult(f.key, undefined, (update.originFilters?.length ?? 0) + i); + if (result) { + f.nonApplicable = !result.applicable; + f.nonApplicableReason = result.reason; + } + }); + this.setState(update); } @@ -851,9 +861,10 @@ export class AdHocFiltersVariable return []; } - const originFilters = this.state.originFilters?.filter((f) => f.key !== filter.key) ?? []; - // Filter out the current filter key from the list of all filters - const otherFilters = this.state.filters.filter((f) => f.key !== filter.key).concat(originFilters); + const originFilters = this.state.originFilters?.filter((f) => f.key !== filter.key && !f.nonApplicable) ?? []; + const otherFilters = this.state.filters + .filter((f) => f.key !== filter.key && !f.nonApplicable) + .concat(originFilters); const timeRange = sceneGraph.getTimeRange(this).state.value; const queries = this.state.useQueriesAsFilterForOptions ? getQueriesForVariables(this) : undefined; diff --git a/packages/scenes/src/variables/applicabilityUtils.test.ts b/packages/scenes/src/variables/applicabilityUtils.test.ts new file mode 100644 index 000000000..9a3ba9c63 --- /dev/null +++ b/packages/scenes/src/variables/applicabilityUtils.test.ts @@ -0,0 +1,120 @@ +import { buildApplicabilityMatcher } from './applicabilityUtils'; + +describe('buildApplicabilityMatcher', () => { + it('should match entries by key', () => { + const response = [ + { key: 'env', applicable: true }, + { key: 'cluster', applicable: false, reason: 'not found' }, + ]; + + const match = buildApplicabilityMatcher(response); + + expect(match('env')).toEqual({ key: 'env', applicable: true }); + expect(match('cluster')).toEqual({ key: 'cluster', applicable: false, reason: 'not found' }); + }); + + it('should match entries by key + origin', () => { + const response = [ + { key: 'env', applicable: true }, + { key: 'region', applicable: false, origin: 'dashboard' }, + ]; + + const match = buildApplicabilityMatcher(response); + + expect(match('env')).toEqual({ key: 'env', applicable: true }); + expect(match('region', 'dashboard')).toEqual({ key: 'region', applicable: false, origin: 'dashboard' }); + }); + + it('should return undefined for unknown keys', () => { + const response = [{ key: 'env', applicable: true }]; + + const match = buildApplicabilityMatcher(response); + + expect(match('unknown')).toBeUndefined(); + }); + + it('should return undefined for empty response', () => { + const match = buildApplicabilityMatcher([]); + + expect(match('env')).toBeUndefined(); + }); + + it('should return the last entry for duplicate keys', () => { + const response = [ + { key: 'env', applicable: true }, + { key: 'env', applicable: false, reason: 'value not found' }, + ]; + + const match = buildApplicabilityMatcher(response); + + expect(match('env')).toEqual({ key: 'env', applicable: false, reason: 'value not found' }); + }); + + it('should return the same result on repeated lookups (stateless)', () => { + const response = [{ key: 'env', applicable: true }]; + + const match = buildApplicabilityMatcher(response); + + expect(match('env')).toEqual({ key: 'env', applicable: true }); + expect(match('env')).toEqual({ key: 'env', applicable: true }); + expect(match('env')).toEqual({ key: 'env', applicable: true }); + }); + + it('should keep key-only and key+origin entries separate', () => { + const response = [ + { key: 'env', applicable: true }, + { key: 'env', applicable: false, origin: 'dashboard' }, + ]; + + const match = buildApplicabilityMatcher(response); + + expect(match('env')).toEqual({ key: 'env', applicable: true }); + expect(match('env', 'dashboard')).toEqual({ key: 'env', applicable: false, origin: 'dashboard' }); + }); + + it('should not match key+origin entry when queried without origin', () => { + const response = [{ key: 'region', applicable: false, origin: 'dashboard' }]; + + const match = buildApplicabilityMatcher(response); + + expect(match('region')).toBeUndefined(); + expect(match('region', 'dashboard')).toEqual({ key: 'region', applicable: false, origin: 'dashboard' }); + }); + + it('should not match key-only entry when queried with origin', () => { + const response = [{ key: 'region', applicable: true }]; + + const match = buildApplicabilityMatcher(response); + + expect(match('region', 'dashboard')).toBeUndefined(); + expect(match('region')).toEqual({ key: 'region', applicable: true }); + }); + + it('should handle mixed keys and origins correctly', () => { + const response = [ + { key: 'cluster', applicable: false, reason: 'overridden' }, + { key: 'env', applicable: true, origin: 'scope' }, + { key: 'pod', applicable: true }, + { key: 'cluster', applicable: true, origin: 'dashboard' }, + ]; + + const match = buildApplicabilityMatcher(response); + + expect(match('pod')).toEqual({ key: 'pod', applicable: true }); + expect(match('env', 'scope')).toEqual({ key: 'env', applicable: true, origin: 'scope' }); + expect(match('cluster')).toEqual({ key: 'cluster', applicable: false, reason: 'overridden' }); + expect(match('cluster', 'dashboard')).toEqual({ key: 'cluster', applicable: true, origin: 'dashboard' }); + }); + + it('should handle multiple origins for the same key', () => { + const response = [ + { key: 'env', applicable: true, origin: 'scope' }, + { key: 'env', applicable: false, origin: 'dashboard' }, + ]; + + const match = buildApplicabilityMatcher(response); + + expect(match('env', 'scope')).toEqual({ key: 'env', applicable: true, origin: 'scope' }); + expect(match('env', 'dashboard')).toEqual({ key: 'env', applicable: false, origin: 'dashboard' }); + }); +}); diff --git a/packages/scenes/src/variables/applicabilityUtils.ts b/packages/scenes/src/variables/applicabilityUtils.ts new file mode 100644 index 000000000..986e90bfd --- /dev/null +++ b/packages/scenes/src/variables/applicabilityUtils.ts @@ -0,0 +1,38 @@ +import { + // @ts-expect-error (temporary till we update grafana/data) + DrilldownsApplicability, +} from '@grafana/data'; + +const COMPOSITE_KEY_SEPARATOR = '|'; + +function compositeKey(key: string, origin?: string, index?: number): string { + let result = origin ? `${key}${COMPOSITE_KEY_SEPARATOR}${origin}` : key; + if (index !== undefined) { + result += `${COMPOSITE_KEY_SEPARATOR}${index}`; + } + return result; +} + +/** + * Builds a lookup from a DS applicability response. + * + * Each entry is stored under both an index-aware key (`key|origin|index`) + * and a plain key (`key|origin`). Pass `index` when the response can + * contain duplicate keys (e.g. two user filters with the same label) to + * get precise positional matching. Without `index`, duplicate keys + * collapse to last-wins which is safe for inherently-unique sets like + * groupBy keys. + */ +export function buildApplicabilityMatcher(response: DrilldownsApplicability[]) { + const map = new Map(); + + for (let i = 0; i < response.length; i++) { + const entry = response[i]; + map.set(compositeKey(entry.key, entry.origin, i), entry); + map.set(compositeKey(entry.key, entry.origin), entry); + } + + return (key: string, origin?: string, index?: number): DrilldownsApplicability | undefined => { + return map.get(compositeKey(key, origin, index)); + }; +} diff --git a/packages/scenes/src/variables/groupby/GroupByRecommendations.test.tsx b/packages/scenes/src/variables/groupby/GroupByRecommendations.test.tsx index 6fa3e2255..8c30cb2ea 100644 --- a/packages/scenes/src/variables/groupby/GroupByRecommendations.test.tsx +++ b/packages/scenes/src/variables/groupby/GroupByRecommendations.test.tsx @@ -189,7 +189,7 @@ describe('GroupByRecommendations', () => { describe('addValueToParent', () => { it('should add value to parent variable', async () => { const getRecommendedDrilldowns = jest.fn().mockResolvedValue(undefined); - const getDrilldownsApplicability = jest.fn().mockResolvedValue(undefined); + const getDrilldownsApplicability = jest.fn().mockResolvedValue(new Map([['_default_', []]])); const { variable } = setupTest( { @@ -261,11 +261,14 @@ describe('GroupByRecommendations', () => { describe('integration with parent variable', () => { it('should store recent grouping when parent calls _verifyApplicabilityAndStoreRecentGrouping', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([{ key: 'value1', applicable: true }]); + const getDrilldownsApplicabilitySpy = jest + .fn() + .mockResolvedValue(new Map([['_default_', [{ key: 'value1', applicable: true }]]])); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['value1'], }, { @@ -293,11 +296,14 @@ describe('GroupByRecommendations', () => { }); it('should not store non-applicable values', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([{ key: 'value1', applicable: false }]); + const getDrilldownsApplicabilitySpy = jest + .fn() + .mockResolvedValue(new Map([['_default_', [{ key: 'value1', applicable: false }]]])); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['value1'], }, { @@ -367,7 +373,7 @@ interface DsOverrides { function setupTest(overrides?: Partial, dsOverrides?: DsOverrides) { const getGroupByKeysSpy = jest.fn().mockResolvedValue([{ text: 'key3', value: 'key3' }]); const getDrilldownsApplicabilitySpy = - dsOverrides?.getDrilldownsApplicability ?? jest.fn().mockResolvedValue(undefined); + dsOverrides?.getDrilldownsApplicability ?? jest.fn().mockResolvedValue(new Map([['_default_', []]])); const getRecommendedDrilldownsSpy = dsOverrides?.getRecommendedDrilldowns ?? jest.fn().mockResolvedValue(undefined); setDataSourceSrv({ diff --git a/packages/scenes/src/variables/groupby/GroupByRecommendations.tsx b/packages/scenes/src/variables/groupby/GroupByRecommendations.tsx index 6018a26b6..7689db80a 100644 --- a/packages/scenes/src/variables/groupby/GroupByRecommendations.tsx +++ b/packages/scenes/src/variables/groupby/GroupByRecommendations.tsx @@ -1,10 +1,5 @@ import React from 'react'; -import { - // @ts-expect-error (temporary till we update grafana/data) - DrilldownsApplicability, - SelectableValue, - store, -} from '@grafana/data'; +import { SelectableValue, store } from '@grafana/data'; import { sceneGraph } from '../../core/sceneGraph'; import { getEnrichedDataRequest } from '../../querying/getEnrichedDataRequest'; import { getQueriesForVariables } from '../utils'; @@ -18,6 +13,7 @@ import { VariableValueSingle } from '../types'; import { isArray } from 'lodash'; import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneComponentProps, SceneObjectState } from '../../core/types'; + import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject'; import { Unsubscribable } from 'rxjs'; @@ -54,11 +50,7 @@ export class GroupByRecommendations extends SceneObjectBase 0) { - this._verifyRecentGroupingsApplicability(storedGroupings); - } else { - this.setState({ recentGrouping: [] }); - } + this.setState({ recentGrouping: storedGroupings.slice(-MAX_RECENT_DRILLDOWNS) }); this._fetchRecommendedDrilldowns(); @@ -71,13 +63,6 @@ export class GroupByRecommendations extends SceneObjectBase { if (newState.scopes !== prevState.scopes) { - const json = store.get(this._getStorageKey()); - const storedGroupings = json ? JSON.parse(json) : []; - - if (storedGroupings.length > 0) { - this._verifyRecentGroupingsApplicability(storedGroupings); - } - this._fetchRecommendedDrilldowns(); } })) @@ -87,13 +72,6 @@ export class GroupByRecommendations extends SceneObjectBase { if (newState.value !== prevState.value) { - const json = store.get(this._getStorageKey()); - const storedGroupings = json ? JSON.parse(json) : []; - - if (storedGroupings.length > 0) { - this._verifyRecentGroupingsApplicability(storedGroupings); - } - this._fetchRecommendedDrilldowns(); } })) @@ -149,31 +127,6 @@ export class GroupByRecommendations extends SceneObjectBase>) { - const queries = getQueriesForVariables(this._groupBy); - const keys = storedGroupings.map((g) => String(g.value)); - const response = await this._groupBy.getGroupByApplicabilityForQueries(keys, queries); - - if (!response) { - this.setState({ recentGrouping: storedGroupings.slice(-MAX_RECENT_DRILLDOWNS) }); - return; - } - - const applicabilityMap = new Map(); - response.forEach((item: DrilldownsApplicability) => { - applicabilityMap.set(item.key, item.applicable !== false); - }); - - const applicableGroupings = storedGroupings - .filter((g) => { - const isApplicable = applicabilityMap.get(String(g.value)); - return isApplicable === undefined || isApplicable === true; - }) - .slice(-MAX_RECENT_DRILLDOWNS); - - this.setState({ recentGrouping: applicableGroupings }); - } - /** * Stores recent groupings in localStorage and updates state. * Should be called by the parent variable when a grouping is added/updated. diff --git a/packages/scenes/src/variables/groupby/GroupByVariable.test.tsx b/packages/scenes/src/variables/groupby/GroupByVariable.test.tsx index 36a3a4dc2..836183dc3 100644 --- a/packages/scenes/src/variables/groupby/GroupByVariable.test.tsx +++ b/packages/scenes/src/variables/groupby/GroupByVariable.test.tsx @@ -567,12 +567,19 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { describe('_verifyApplicability', () => { it('should call getDrilldownsApplicability and update keysApplicability state', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([ - { key: 'key1', applicable: true }, - { key: 'key2', applicable: false }, - ]); + const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue( + new Map([ + [ + '_default_', + [ + { key: 'key1', applicable: true }, + { key: 'key2', applicable: false }, + ], + ], + ]) + ); - const { variable } = setupTest({ value: ['key1', 'key2'] }, undefined, undefined, { + const { variable } = setupTest({ value: ['key1', 'key2'], applicabilityEnabled: true }, undefined, undefined, { // @ts-expect-error (temporary till we update grafana/data) getDrilldownsApplicability: getDrilldownsApplicabilitySpy, }); @@ -600,7 +607,7 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should not set keysApplicability if data source does not support it', async () => { - const { variable } = setupTest({ value: ['key1'] }); + const { variable } = setupTest({ value: ['key1'], applicabilityEnabled: true }); await act(async () => { await variable._verifyApplicability(); @@ -612,7 +619,7 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { it('should handle empty response from getDrilldownsApplicability', async () => { const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue(null); - const { variable } = setupTest({ value: ['key1'] }, undefined, undefined, { + const { variable } = setupTest({ value: ['key1'], applicabilityEnabled: true }, undefined, undefined, { // @ts-expect-error (temporary till we update grafana/data) getDrilldownsApplicability: getDrilldownsApplicabilitySpy, }); @@ -626,9 +633,11 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should be called during activation handler', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([{ key: 'key1', applicable: true }]); + const getDrilldownsApplicabilitySpy = jest + .fn() + .mockResolvedValue(new Map([['_default_', [{ key: 'key1', applicable: true }]]])); - const { variable } = setupTest({ value: ['key1'] }, undefined, undefined, { + const { variable } = setupTest({ value: ['key1'], applicabilityEnabled: true }, undefined, undefined, { // @ts-expect-error (temporary till we update grafana/data) getDrilldownsApplicability: getDrilldownsApplicabilitySpy, }); @@ -643,10 +652,17 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should pass values to verifyApplicabilitySpy on blur', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([ - { key: 'existingKey', applicable: true }, - { key: 'newTypedKey', applicable: false }, - ]); + const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue( + new Map([ + [ + '_default_', + [ + { key: 'existingKey', applicable: true }, + { key: 'newTypedKey', applicable: false }, + ], + ], + ]) + ); const { variable } = setupTest( { @@ -656,6 +672,7 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { { text: 'option2', value: 'option2' }, ], allowCustomValue: true, + applicabilityEnabled: true, }, undefined, undefined, @@ -814,11 +831,14 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should add applicable keys to recentGrouping and store in localStorage after verifyApplicabilityAndStoreRecentGrouping', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([{ key: 'value1', applicable: true }]); + const getDrilldownsApplicabilitySpy = jest + .fn() + .mockResolvedValue(new Map([['_default_', [{ key: 'value1', applicable: true }]]])); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['value1'], }, undefined, @@ -843,11 +863,14 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should not store non-applicable keys in recentGrouping', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([{ key: 'value1', applicable: false }]); + const getDrilldownsApplicabilitySpy = jest + .fn() + .mockResolvedValue(new Map([['_default_', [{ key: 'value1', applicable: false }]]])); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['value1'], }, undefined, @@ -870,15 +893,23 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should only store applicable keys when some are non-applicable', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([ - { key: 'value1', applicable: true }, - { key: 'value2', applicable: false }, - { key: 'value3', applicable: true }, - ]); + const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue( + new Map([ + [ + '_default_', + [ + { key: 'value1', applicable: true }, + { key: 'value2', applicable: false }, + { key: 'value3', applicable: true }, + ], + ], + ]) + ); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['value1', 'value2', 'value3'], }, undefined, @@ -910,16 +941,24 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { } localStorage.setItem(RECENT_GROUPING_KEY, JSON.stringify(existingGroupings)); - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([ - { key: 'newValue1', applicable: true }, - { key: 'newValue2', applicable: true }, - { key: 'newValue3', applicable: true }, - { key: 'newValue4', applicable: true }, - ]); + const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue( + new Map([ + [ + '_default_', + [ + { key: 'newValue1', applicable: true }, + { key: 'newValue2', applicable: true }, + { key: 'newValue3', applicable: true }, + { key: 'newValue4', applicable: true }, + ], + ], + ]) + ); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['newValue1', 'newValue2', 'newValue3', 'newValue4'], }, undefined, @@ -942,14 +981,22 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should set in browser storage with applicable values', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([ - { key: 'value1', applicable: true }, - { key: 'value2', applicable: true }, - ]); + const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue( + new Map([ + [ + '_default_', + [ + { key: 'value1', applicable: true }, + { key: 'value2', applicable: true }, + ], + ], + ]) + ); const { variable } = setupTest( { drilldownRecommendationsEnabled: true, + applicabilityEnabled: true, value: ['value1', 'value2'], }, undefined, @@ -970,11 +1017,14 @@ describe.each(['11.1.2', '11.1.1'])('GroupByVariable', (v) => { }); it('should not store anything if drilldownRecommendationsEnabled is false', async () => { - const getDrilldownsApplicabilitySpy = jest.fn().mockResolvedValue([{ key: 'value1', applicable: true }]); + const getDrilldownsApplicabilitySpy = jest + .fn() + .mockResolvedValue(new Map([['_default_', [{ key: 'value1', applicable: true }]]])); const { variable } = setupTest( { drilldownRecommendationsEnabled: false, + applicabilityEnabled: true, value: ['value1'], }, undefined, diff --git a/packages/scenes/src/variables/groupby/GroupByVariable.tsx b/packages/scenes/src/variables/groupby/GroupByVariable.tsx index 24b250b49..699755118 100644 --- a/packages/scenes/src/variables/groupby/GroupByVariable.tsx +++ b/packages/scenes/src/variables/groupby/GroupByVariable.tsx @@ -12,15 +12,9 @@ import { } from '@grafana/data'; import { allActiveGroupByVariables } from './findActiveGroupByVariablesByUid'; import { DataSourceRef, VariableType } from '@grafana/schema'; -import { SceneComponentProps, ControlsLayout, SceneObjectUrlSyncHandler, SceneDataQuery } from '../../core/types'; +import { SceneComponentProps, ControlsLayout, SceneObjectUrlSyncHandler } from '../../core/types'; import { sceneGraph } from '../../core/sceneGraph'; -import { - SceneVariableValueChangedEvent, - ValidateAndUpdateResult, - VariableValue, - VariableValueOption, - VariableValueSingle, -} from '../types'; +import { ValidateAndUpdateResult, VariableValue, VariableValueOption, VariableValueSingle } from '../types'; import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../variants/MultiValueVariable'; import { from, lastValueFrom, map, mergeMap, Observable, of, take, tap } from 'rxjs'; import { getDataSource } from '../../utils/getDataSource'; @@ -220,8 +214,6 @@ export class GroupByVariable extends MultiValueVariable { } private _activationHandler = () => { - this._verifyApplicability(); - if (this.state.defaultValue) { if (this.checkIfRestorable(this.state.value)) { this.setState({ restorable: true }); @@ -232,8 +224,6 @@ export class GroupByVariable extends MultiValueVariable { if (this.state.defaultValue) { this.restoreDefaultValues(); } - - this.setState({ applicabilityEnabled: false }); }; }; @@ -261,48 +251,6 @@ export class GroupByVariable extends MultiValueVariable { return applicableValues; } - public async getGroupByApplicabilityForQueries( - value: VariableValue, - queries: SceneDataQuery[] - ): Promise { - const ds = await getDataSource(this.state.datasource, this._scopedVars); - - // @ts-expect-error (temporary till we update grafana/data) - if (!ds.getDrilldownsApplicability) { - return; - } - - const timeRange = sceneGraph.getTimeRange(this).state.value; - - // @ts-expect-error (temporary till we update grafana/data) - return await ds.getDrilldownsApplicability({ - groupByKeys: Array.isArray(value) ? value.map((v) => String(v)) : value ? [String(value)] : [], - queries, - timeRange, - scopes: sceneGraph.getScopes(this), - ...getEnrichedFiltersRequest(this), - }); - } - - public async _verifyApplicability() { - const queries = getQueriesForVariables(this); - const value = this.state.value; - - const response = await this.getGroupByApplicabilityForQueries(value, queries); - - if (!response) { - return; - } - - if (!isEqual(response, this.state.keysApplicability)) { - this.setState({ keysApplicability: response ?? undefined, applicabilityEnabled: true }); - - this.publishEvent(new SceneVariableValueChangedEvent(this), true); - } else { - this.setState({ applicabilityEnabled: true }); - } - } - // This method is related to the defaultValue property. We check if the current value // is different from the default value. If it is, the groupBy will show a button // allowing the user to restore the default values. @@ -382,9 +330,7 @@ export class GroupByVariable extends MultiValueVariable { return keys; }; - public async _verifyApplicabilityAndStoreRecentGrouping() { - await this._verifyApplicability(); - + public _verifyApplicabilityAndStoreRecentGrouping() { if (!this._recommendations) { return; }