From b11186f94633b84d0dd5fcc1ff57150d84b002eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Tue, 28 Mar 2023 16:19:27 +0200 Subject: [PATCH] Templating: Optionally save interpolated expressions when replacing variables in a string (#65411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Testing a refactor * update * Update interface, test interoplations map * Refactoring * Add more explicit comment about new behavior * Update packages/grafana-runtime/src/services/templateSrv.ts Co-authored-by: Torkel Ödegaard --------- Co-authored-by: Torkel Ödegaard --- .../src/services/templateSrv.ts | 31 +++- .../features/templating/template_srv.test.ts | 66 ++++++++- .../app/features/templating/template_srv.ts | 135 ++++++++++++------ 3 files changed, 183 insertions(+), 49 deletions(-) diff --git a/packages/grafana-runtime/src/services/templateSrv.ts b/packages/grafana-runtime/src/services/templateSrv.ts index 502e9de2f9a..04113d0cedf 100644 --- a/packages/grafana-runtime/src/services/templateSrv.ts +++ b/packages/grafana-runtime/src/services/templateSrv.ts @@ -1,5 +1,23 @@ import { ScopedVars, TimeRange, TypedVariableModel } from '@grafana/data'; +/** + * Can be used to gain more information about an interpolation operation + */ +export interface VariableInterpolation { + /** The full matched expression including, example: ${varName.field:regex} */ + match: string; + /** In the expression ${varName.field:regex} variableName is varName */ + variableName: string; + /** In the expression ${varName.fields[0].name:regex} the fieldPath is fields[0].name */ + fieldPath?: string; + /** In the expression ${varName:regex} the regex part is the format */ + format?: string; + /** The formatted value of the variable expresion. Will equal match when variable not found or scopedVar was undefined or null **/ + value: string; + // When value === match this will be true, meaning the variable was not found + found?: boolean; +} + /** * Via the TemplateSrv consumers get access to all the available template variables * that can be used within the current active dashboard. @@ -15,8 +33,19 @@ export interface TemplateSrv { /** * Replace the values within the target string. See also {@link InterpolateFunction} + * + * Note: interpolations array is being mutated by replace function by adding information about variables that + * have been interpolated during replacement. Variables that were specified in the target but not found in + * the list of available variables are also added to the array. See {@link VariableInterpolation} for more details. + * + * @param {VariableInterpolation[]} interpolations an optional map that is updated with interpolated variables */ - replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string; + replace( + target?: string, + scopedVars?: ScopedVars, + format?: string | Function, + interpolations?: VariableInterpolation[] + ): string; /** * Checks if a target contains template variables. diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index a4498ee3225..ae75c3ae3dc 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -1,5 +1,5 @@ import { dateTime, TimeRange } from '@grafana/data'; -import { setDataSourceSrv } from '@grafana/runtime'; +import { setDataSourceSrv, VariableInterpolation } from '@grafana/runtime'; import { FormatRegistryID, TestVariable } from '@grafana/scenes'; import { VariableFormatID } from '@grafana/schema'; @@ -128,6 +128,70 @@ describe('templateSrv', () => { }); }); + describe('replace with interpolations map', function () { + beforeEach(() => { + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'testValue' } }]); + }); + + it('replace can save interpolation result', () => { + let interpolations: VariableInterpolation[] = []; + + const target = _templateSrv.replace( + 'test.${test}.${scoped}.${nested.name}.${test}.${optionTest:raw}.$notfound', + { + scoped: { value: 'scopedValue', text: 'scopedText' }, + optionTest: { value: 'optionTestValue', text: 'optionTestText' }, + nested: { value: { name: 'nestedValue' } }, + }, + undefined, + interpolations + ); + + expect(target).toBe('test.testValue.scopedValue.nestedValue.testValue.optionTestValue.$notfound'); + expect(interpolations.length).toBe(6); + expect(interpolations).toEqual([ + { + match: '${test}', + found: true, + value: 'testValue', + variableName: 'test', + }, + { + match: '${scoped}', + found: true, + value: 'scopedValue', + variableName: 'scoped', + }, + { + fieldPath: 'name', + match: '${nested.name}', + found: true, + value: 'nestedValue', + variableName: 'nested', + }, + { + match: '${test}', + found: true, + value: 'testValue', + variableName: 'test', + }, + { + format: 'raw', + match: '${optionTest:raw}', + found: true, + value: 'optionTestValue', + variableName: 'optionTest', + }, + { + match: '$notfound', + found: false, + value: '$notfound', + variableName: 'notfound', + }, + ]); + }); + }); + describe('getAdhocFilters', () => { beforeEach(() => { _templateSrv = initTemplateSrv(key, [ diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 2368b5ae9ac..a2b9cfb0923 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -8,7 +8,12 @@ import { AdHocVariableModel, TypedVariableModel, } from '@grafana/data'; -import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime'; +import { + getDataSourceSrv, + setTemplateSrv, + TemplateSrv as BaseTemplateSrv, + VariableInterpolation, +} from '@grafana/runtime'; import { sceneGraph, FormatRegistryID, formatRegistry, VariableCustomFormatterFn } from '@grafana/scenes'; import { variableAdapters } from '../variables/adapters'; @@ -23,6 +28,11 @@ interface FieldAccessorCache { [key: string]: (obj: any) => any; } +/** + * Internal regex replace function + */ +type ReplaceFunction = (fullMatch: string, variableName: string, fieldPath: string, format: string) => string; + export interface TemplateSrvDependencies { getFilteredVariables: typeof getFilteredVariables; getVariables: typeof getVariables; @@ -219,9 +229,8 @@ export class TemplateSrv implements BaseTemplateSrv { } str = escape(str); - this.regex.lastIndex = 0; - return str.replace(this.regex, (match, var1, var2, fmt2, var3) => { - if (this.getVariableAtIndex(var1 || var2 || var3)) { + return this._replaceWithVariableRegex(str, undefined, (match, variableName) => { + if (this.getVariableAtIndex(variableName)) { return '' + match + ''; } return match; @@ -275,7 +284,12 @@ export class TemplateSrv implements BaseTemplateSrv { return value; } - replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string { + replace( + target?: string, + scopedVars?: ScopedVars, + format?: string | Function, + interpolations?: VariableInterpolation[] + ): string { if (scopedVars && scopedVars.__sceneObject) { return sceneGraph.interpolate( scopedVars.__sceneObject.value, @@ -291,63 +305,90 @@ export class TemplateSrv implements BaseTemplateSrv { this.regex.lastIndex = 0; - return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { - const variableName = var1 || var2 || var3; - const variable = this.getVariableAtIndex(variableName); - let fmt = fmt2 || fmt3 || format; + return this._replaceWithVariableRegex(target, format, (match, variableName, fieldPath, fmt) => { + const value = this._evaluateVariableExpression(match, variableName, fieldPath, fmt, scopedVars); - if (scopedVars) { - const value = this.getVariableValue(variableName, fieldPath, scopedVars); - const text = this.getVariableText(variableName, value, scopedVars); + // If we get passed this interpolations map we will also record all the expressions that were replaced + if (interpolations) { + interpolations.push({ match, variableName, fieldPath, format: fmt, value, found: value !== match }); + } - if (value !== null && value !== undefined) { - if (scopedVars[variableName].skipFormat) { - fmt = undefined; - } + return value; + }); + } - return this.formatValue(value, fmt, variable, text); + private _evaluateVariableExpression( + match: string, + variableName: string, + fieldPath: string, + format: string | Function | undefined, + scopedVars: ScopedVars | undefined + ) { + const variable = this.getVariableAtIndex(variableName); + + if (scopedVars) { + const value = this.getVariableValue(variableName, fieldPath, scopedVars); + const text = this.getVariableText(variableName, value, scopedVars); + + if (value !== null && value !== undefined) { + if (scopedVars[variableName]?.skipFormat) { + format = undefined; } - } - if (!variable) { - return match; + return this.formatValue(value, format, variable, text); } + } - if (fmt === FormatRegistryID.queryParam || isAdHoc(variable)) { - const value = variableAdapters.get(variable.type).getValueForUrl(variable); - const text = isAdHoc(variable) ? variable.id : variable.current.text; + if (!variable) { + return match; + } - return this.formatValue(value, fmt, variable, text); - } + if (format === FormatRegistryID.queryParam || isAdHoc(variable)) { + const value = variableAdapters.get(variable.type).getValueForUrl(variable); + const text = isAdHoc(variable) ? variable.id : variable.current.text; - const systemValue = this.grafanaVariables.get(variable.current.value); - if (systemValue) { - return this.formatValue(systemValue, fmt, variable); - } + return this.formatValue(value, format, variable, text); + } - let value = variable.current.value; - let text = variable.current.text; + const systemValue = this.grafanaVariables.get(variable.current.value); + if (systemValue) { + return this.formatValue(systemValue, format, variable); + } - if (this.isAllValue(value)) { - value = this.getAllValue(variable); - text = ALL_VARIABLE_TEXT; - // skip formatting of custom all values unless format set to text or percentencode - if (variable.allValue && fmt !== FormatRegistryID.text && fmt !== FormatRegistryID.percentEncode) { - return this.replace(value); - } + let value = variable.current.value; + let text = variable.current.text; + + if (this.isAllValue(value)) { + value = this.getAllValue(variable); + text = ALL_VARIABLE_TEXT; + // skip formatting of custom all values unless format set to text or percentencode + if (variable.allValue && format !== FormatRegistryID.text && format !== FormatRegistryID.percentEncode) { + return this.replace(value); } + } - if (fieldPath) { - const fieldValue = this.getVariableValue(variableName, fieldPath, { - [variableName]: { value, text }, - }); - if (fieldValue !== null && fieldValue !== undefined) { - return this.formatValue(fieldValue, fmt, variable, text); - } + if (fieldPath) { + const fieldValue = this.getVariableValue(variableName, fieldPath, { + [variableName]: { value, text }, + }); + if (fieldValue !== null && fieldValue !== undefined) { + return this.formatValue(fieldValue, format, variable, text); } + } - const res = this.formatValue(value, fmt, variable, text); - return res; + return this.formatValue(value, format, variable, text); + } + + /** + * Tries to unify the different variable format capture groups into a simpler replacer function + */ + private _replaceWithVariableRegex(text: string, format: string | Function | undefined, replace: ReplaceFunction) { + this.regex.lastIndex = 0; + + return text.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const variableName = var1 || var2 || var3; + const fmt = fmt2 || fmt3 || format; + return replace(match, variableName, fieldPath, fmt); }); }