diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 8207fa21988..23c2f7be0b3 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -1,5 +1,6 @@ import { ArrayVector, + DataFrame, DataLink, dateTime, Field, @@ -7,9 +8,11 @@ import { InterpolateFunction, LinkModel, TimeRange, + toDataFrame, } from '@grafana/data'; import { setTemplateSrv } from '@grafana/runtime'; +import { initTemplateSrv } from '../../../../test/helpers/initTemplateSrv'; import { setContextSrv } from '../../../core/services/context_srv'; import { setLinkSrv } from '../../panel/panellinks/link_srv'; @@ -17,18 +20,13 @@ import { getFieldLinksForExplore } from './links'; describe('getFieldLinksForExplore', () => { beforeEach(() => { - setTemplateSrv({ - replace(target, scopedVars, format) { - return target ?? ''; - }, - getVariables() { - return []; - }, - containsTemplate() { - return false; - }, - updateTimeRange(timeRange: TimeRange) {}, - }); + setTemplateSrv( + initTemplateSrv('key', [ + { type: 'custom', name: 'emptyVar', current: { value: null } }, + { type: 'custom', name: 'num', current: { value: 1 } }, + { type: 'custom', name: 'test', current: { value: 'foo' } }, + ]) + ); }); it('returns correct link model for external link', () => { @@ -36,7 +34,12 @@ describe('getFieldLinksForExplore', () => { title: 'external', url: 'http://regionalhost', }); - const links = getFieldLinksForExplore({ field, rowIndex: 0, splitOpenFn: jest.fn(), range }); + const links = getFieldLinksForExplore({ + field, + rowIndex: ROW_WITH_TEXT_VALUE.index, + splitOpenFn: jest.fn(), + range, + }); expect(links[0].href).toBe('http://regionalhost'); expect(links[0].title).toBe('external'); @@ -47,7 +50,12 @@ describe('getFieldLinksForExplore', () => { title: '', url: 'http://regionalhost', }); - const links = getFieldLinksForExplore({ field, rowIndex: 0, splitOpenFn: jest.fn(), range }); + const links = getFieldLinksForExplore({ + field, + rowIndex: ROW_WITH_TEXT_VALUE.index, + splitOpenFn: jest.fn(), + range, + }); expect(links[0].href).toBe('http://regionalhost'); expect(links[0].title).toBe('regionalhost'); @@ -69,7 +77,7 @@ describe('getFieldLinksForExplore', () => { }, }); const splitfn = jest.fn(); - const links = getFieldLinksForExplore({ field, rowIndex: 0, splitOpenFn: splitfn, range }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, splitOpenFn: splitfn, range }); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( @@ -102,7 +110,7 @@ describe('getFieldLinksForExplore', () => { }, false ); - const links = getFieldLinksForExplore({ field, rowIndex: 0, range }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range }); expect(links[0].href).toBe('http://regionalhost'); expect(links[0].title).toBe('external'); @@ -121,11 +129,42 @@ describe('getFieldLinksForExplore', () => { }, false ); - const links = getFieldLinksForExplore({ field, rowIndex: 0, range }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range }); + expect(links).toHaveLength(0); + }); + + it('returns internal links when target contains defined template variables', () => { + const { field, range, dataFrame } = setup({ + title: '', + url: '', + internal: { + query: { query: 'query_1-${__data.fields.flux-dimensions}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); + expect(links).toHaveLength(1); + }); + + it('returns no internal links when target contains empty template variables', () => { + const { field, range, dataFrame } = setup({ + title: '', + url: '', + internal: { + query: { query: 'query_1-${__data.fields.flux-dimensions}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_NULL_VALUE.index, range, dataFrame }); expect(links).toHaveLength(0); }); }); +const ROW_WITH_TEXT_VALUE = { value: 'foo', index: 0 }; +const ROW_WITH_NULL_VALUE = { value: null, index: 1 }; + function setup(link: DataLink, hasAccess = true) { setLinkSrv({ getDataLinkUIModel(link: DataLink, replaceVariables: InterpolateFunction | undefined, origin: any): LinkModel { @@ -148,15 +187,19 @@ function setup(link: DataLink, hasAccess = true) { hasAccessToExplore: () => hasAccess, } as any); - const field: Field = { + const field: Field = { name: 'flux-dimensions', type: FieldType.string, - values: new ArrayVector([]), + values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]), config: { links: [link], }, }; + const dataFrame: DataFrame = toDataFrame({ + fields: [field], + }); + const range: TimeRange = { from: dateTime('2020-10-14T00:00:00'), to: dateTime('2020-10-14T01:00:00'), @@ -166,5 +209,5 @@ function setup(link: DataLink, hasAccess = true) { }, }; - return { range, field }; + return { range, field, dataFrame }; } diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 92198743149..ad0f27341e0 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -10,12 +10,44 @@ import { DataFrame, getFieldDisplayValuesProxy, SplitOpen, + DataLink, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; +type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean; + +const dataLinkHasRequiredPermissions = (link: DataLink) => { + return !link.internal || contextSrv.hasAccessToExplore(); +}; + +const dataLinkHasAllVariablesDefined = (link: DataLink, scopedVars: ScopedVars) => { + let hasAllRequiredVarDefined = true; + + if (link.internal) { + let stringifiedQuery = ''; + try { + stringifiedQuery = JSON.stringify(link.internal.query || {}); + // Hook into format function to verify if all values are non-empty + // Format function is run on all existing field values allowing us to check it's value is non-empty + getTemplateSrv().replace(stringifiedQuery, scopedVars, (f: string) => { + hasAllRequiredVarDefined = hasAllRequiredVarDefined && !!f; + return ''; + }); + } catch (err) {} + } + + return hasAllRequiredVarDefined; +}; + +/** + * Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not + * be passed back to the visualization. + */ +const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasAllVariablesDefined, dataLinkHasRequiredPermissions]; + /** * Get links from the field of a dataframe and in addition check if there is associated * metadata with datasource in which case we will add onClick to open the link in new split window. This assumes @@ -56,13 +88,9 @@ export const getFieldLinksForExplore = (options: { } if (field.config.links) { - const links = []; - - if (!contextSrv.hasAccessToExplore()) { - links.push(...field.config.links.filter((l) => !l.internal)); - } else { - links.push(...field.config.links); - } + const links = field.config.links.filter((link) => { + return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars)); + }); return links.map((link) => { if (!link.internal) {