diff --git a/.betterer.results b/.betterer.results index bfb01521da9..9b47eddaeb4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4708,13 +4708,6 @@ exports[`better eslint`] = { "public/app/features/teams/state/selectors.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/templating/formatRegistry.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] - ], "public/app/features/templating/template_srv.mock.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/e2e/dashboards-suite/dashboard-templating.spec.ts b/e2e/dashboards-suite/dashboard-templating.spec.ts index 913a8d181d7..f555d05fecc 100644 --- a/e2e/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e/dashboards-suite/dashboard-templating.spec.ts @@ -33,7 +33,7 @@ e2e.scenario({ `Server:singlequote = 'A\\'A"A','BB\\B','CCC'`, `Server:doublequote = "A'A\\"A","BB\\B","CCC"`, `Server:sqlstring = 'A''A"A','BB\\\B','CCC'`, - `Server:date = null`, + `Server:date = NaN`, `Server:text = All`, `Server:queryparam = var-Server=All`, `1 < 2`, diff --git a/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts index ff555e5a9bf..1b724265d7a 100644 --- a/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts +++ b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts @@ -2,19 +2,19 @@ import { property } from 'lodash'; import { ScopedVar } from '@grafana/data'; -import { SceneObjectBase } from '../../core/SceneObjectBase'; -import { SceneVariable, SceneVariableState, VariableValue } from '../types'; +import { VariableValue } from '../types'; -export interface ScopedVarsProxyVariableState extends SceneVariableState { - value: ScopedVar; -} +import { FormatVariable } from './formatRegistry'; -export class ScopedVarsVariable - extends SceneObjectBase - implements SceneVariable -{ +export class ScopedVarsVariable implements FormatVariable { private static fieldAccessorCache: FieldAccessorCache = {}; + public state: { name: string; value: ScopedVar }; + + public constructor(name: string, value: ScopedVar) { + this.state = { name, value }; + } + public getValue(fieldPath: string): VariableValue { let { value } = this.state; let realValue = value.value; @@ -63,9 +63,10 @@ let scopedVarsVariable: ScopedVarsVariable | undefined; */ export function getSceneVariableForScopedVar(name: string, value: ScopedVar) { if (!scopedVarsVariable) { - scopedVarsVariable = new ScopedVarsVariable({ name, value }); + scopedVarsVariable = new ScopedVarsVariable(name, value); } else { - scopedVarsVariable.setState({ name, value }); + scopedVarsVariable.state.name = name; + scopedVarsVariable.state.value = value; } return scopedVarsVariable; diff --git a/public/app/features/scenes/variables/interpolation/formatRegistry.ts b/public/app/features/scenes/variables/interpolation/formatRegistry.ts index e216ee58418..3d4e3fe6148 100644 --- a/public/app/features/scenes/variables/interpolation/formatRegistry.ts +++ b/public/app/features/scenes/variables/interpolation/formatRegistry.ts @@ -4,10 +4,24 @@ import { dateTime, Registry, RegistryItem, textUtil } from '@grafana/data'; import kbn from 'app/core/utils/kbn'; import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; -import { SceneVariable, VariableValue, VariableValueSingle } from '../types'; +import { VariableValue, VariableValueSingle } from '../types'; export interface FormatRegistryItem extends RegistryItem { - formatter(value: VariableValue, args: string[], variable: SceneVariable): string; + formatter(value: VariableValue, args: string[], variable: FormatVariable): string; +} + +/** + * Slimmed down version of the SceneVariable interface so that it only contains what the formatters actually use. + * This is useful as we have some implementations of this interface that does not need to be full scene objects. + * For example ScopedVarsVariable and LegacyVariableWrapper. + */ +export interface FormatVariable { + state: { + name: string; + }; + + getValue(fieldPath?: string): VariableValue | undefined | null; + getValueText?(fieldPath?: string): string; } export enum FormatRegistryID { @@ -231,14 +245,16 @@ export const formatRegistry = new Registry(() => { name: 'Date', description: 'Format date in different ways', formatter: (value, args) => { - let nrValue = 0; + let nrValue = NaN; if (typeof value === 'number') { nrValue = value; } else if (typeof value === 'string') { nrValue = parseInt(value, 10); - } else { - return ''; + } + + if (isNaN(nrValue)) { + return 'NaN'; } const arg = args[0] ?? 'iso'; @@ -270,10 +286,6 @@ export const formatRegistry = new Registry(() => { name: 'Text', description: 'Format variables in their text representation. Example in multi-variable scenario A + B + C.', formatter: (value, _args, variable) => { - // if (typeof options.text === 'string') { - // return options.value === ALL_VARIABLE_VALUE ? ALL_VARIABLE_TEXT : options.text; - // } - if (variable.getValueText) { return variable.getValueText(); } diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts index 57345a856a5..06466d7d892 100644 --- a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts @@ -4,10 +4,10 @@ import { variableRegex } from 'app/features/variables/utils'; import { EmptyVariableSet, sceneGraph } from '../../core/sceneGraph'; import { SceneObject } from '../../core/types'; -import { SceneVariable, VariableValue } from '../types'; +import { VariableValue } from '../types'; import { getSceneVariableForScopedVar } from './ScopedVarsVariable'; -import { formatRegistry, FormatRegistryID } from './formatRegistry'; +import { formatRegistry, FormatRegistryID, FormatVariable } from './formatRegistry'; type CustomFormatterFn = ( value: unknown, @@ -42,7 +42,7 @@ export function sceneInterpolator( return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { const variableName = var1 || var2 || var3; const fmt = fmt2 || fmt3 || format; - let variable: SceneVariable | undefined | null; + let variable: FormatVariable | undefined | null; if (scopedVars && scopedVars[variableName]) { variable = getSceneVariableForScopedVar(variableName, scopedVars[variableName]); @@ -58,7 +58,7 @@ export function sceneInterpolator( }); } -function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { +function lookupSceneVariable(name: string, sceneObject: SceneObject): FormatVariable | null | undefined { const variables = sceneObject.state.$variables; if (!variables) { if (sceneObject.parent) { @@ -79,7 +79,7 @@ function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVaria } function formatValue( - variable: SceneVariable, + variable: FormatVariable, value: VariableValue | undefined | null, formatNameOrFn: string | CustomFormatterFn ): string { diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts index a2ac42835fe..d845bffcb85 100644 --- a/public/app/features/scenes/variables/types.ts +++ b/public/app/features/scenes/variables/types.ts @@ -31,7 +31,7 @@ export interface SceneVariable { - describe('with lucene formatter', () => { - const { formatter } = formatRegistry.get(FormatRegistryID.lucene); - - it('should escape single value', () => { - expect( - formatter( - { - value: 'foo bar', - text: '', - args: [], - }, - dummyVar - ) - ).toBe('foo\\ bar'); - }); - - it('should not escape negative number', () => { - expect( - formatter( - { - value: '-1', - text: '', - args: [], - }, - dummyVar - ) - ).toBe('-1'); - }); - - it('should escape string prepended with dash', () => { - expect( - formatter( - { - value: '-test', - text: '', - args: [], - }, - dummyVar - ) - ).toBe('\\-test'); - }); - - it('should escape multi value', () => { - expect( - formatter( - { - value: ['foo bar', 'baz'], - text: '', - args: [], - }, - dummyVar - ) - ).toBe('("foo\\ bar" OR "baz")'); - }); - - it('should escape empty value', () => { - expect( - formatter( - { - value: [], - text: '', - args: [], - }, - dummyVar - ) - ).toBe('__empty__'); - }); - }); -}); diff --git a/public/app/features/templating/formatRegistry.ts b/public/app/features/templating/formatRegistry.ts deleted file mode 100644 index f90039ec879..00000000000 --- a/public/app/features/templating/formatRegistry.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { isArray, map, replace } from 'lodash'; - -import { dateTime, Registry, RegistryItem, textUtil, TypedVariableModel } from '@grafana/data'; -import kbn from 'app/core/utils/kbn'; - -import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants'; -import { formatVariableLabel } from '../variables/shared/formatVariable'; - -export interface FormatOptions { - value: any; - text: string; - args: string[]; -} - -export interface FormatRegistryItem extends RegistryItem { - formatter(options: FormatOptions, variable: TypedVariableModel): string; -} - -export enum FormatRegistryID { - lucene = 'lucene', - raw = 'raw', - regex = 'regex', - pipe = 'pipe', - distributed = 'distributed', - csv = 'csv', - html = 'html', - json = 'json', - percentEncode = 'percentencode', - singleQuote = 'singlequote', - doubleQuote = 'doublequote', - sqlString = 'sqlstring', - date = 'date', - glob = 'glob', - text = 'text', - queryParam = 'queryparam', -} - -export const formatRegistry = new Registry(() => { - const formats: FormatRegistryItem[] = [ - { - id: FormatRegistryID.lucene, - name: 'Lucene', - description: 'Values are lucene escaped and multi-valued variables generate an OR expression', - formatter: ({ value }) => { - if (typeof value === 'string') { - return luceneEscape(value); - } - - if (value instanceof Array && value.length === 0) { - return '__empty__'; - } - - const quotedValues = map(value, (val: string) => { - return '"' + luceneEscape(val) + '"'; - }); - - return '(' + quotedValues.join(' OR ') + ')'; - }, - }, - { - id: FormatRegistryID.raw, - name: 'raw', - description: 'Keep value as is', - formatter: ({ value }) => value, - }, - { - id: FormatRegistryID.regex, - name: 'Regex', - description: 'Values are regex escaped and multi-valued variables generate a (|) expression', - formatter: ({ value }) => { - if (typeof value === 'string') { - return kbn.regexEscape(value); - } - - const escapedValues = map(value, kbn.regexEscape); - if (escapedValues.length === 1) { - return escapedValues[0]; - } - return '(' + escapedValues.join('|') + ')'; - }, - }, - { - id: FormatRegistryID.pipe, - name: 'Pipe', - description: 'Values are separated by | character', - formatter: ({ value }) => { - if (typeof value === 'string') { - return value; - } - return value.join('|'); - }, - }, - { - id: FormatRegistryID.distributed, - name: 'Distributed', - description: 'Multiple values are formatted like variable=value', - formatter: ({ value }, variable) => { - if (typeof value === 'string') { - return value; - } - - value = map(value, (val: any, index: number) => { - if (index !== 0) { - return variable.name + '=' + val; - } else { - return val; - } - }); - return value.join(','); - }, - }, - { - id: FormatRegistryID.csv, - name: 'Csv', - description: 'Comma-separated values', - formatter: ({ value }) => { - if (isArray(value)) { - return value.join(','); - } - return value; - }, - }, - { - id: FormatRegistryID.html, - name: 'HTML', - description: 'HTML escaping of values', - formatter: ({ value }) => { - if (isArray(value)) { - return textUtil.escapeHtml(value.join(', ')); - } - return textUtil.escapeHtml(value); - }, - }, - { - id: FormatRegistryID.json, - name: 'JSON', - description: 'JSON stringify valu', - formatter: ({ value }) => { - return JSON.stringify(value); - }, - }, - { - id: FormatRegistryID.percentEncode, - name: 'Percent encode', - description: 'Useful for URL escaping values', - formatter: ({ value }) => { - // like glob, but url escaped - if (isArray(value)) { - return encodeURIComponentStrict('{' + value.join(',') + '}'); - } - return encodeURIComponentStrict(value); - }, - }, - { - id: FormatRegistryID.singleQuote, - name: 'Single quote', - description: 'Single quoted values', - formatter: ({ value }) => { - // escape single quotes with backslash - const regExp = new RegExp(`'`, 'g'); - if (isArray(value)) { - return map(value, (v: string) => `'${replace(v, regExp, `\\'`)}'`).join(','); - } - return `'${replace(value, regExp, `\\'`)}'`; - }, - }, - { - id: FormatRegistryID.doubleQuote, - name: 'Double quote', - description: 'Double quoted values', - formatter: ({ value }) => { - // escape double quotes with backslash - const regExp = new RegExp('"', 'g'); - if (isArray(value)) { - return map(value, (v: string) => `"${replace(v, regExp, '\\"')}"`).join(','); - } - return `"${replace(value, regExp, '\\"')}"`; - }, - }, - { - id: FormatRegistryID.sqlString, - name: 'SQL string', - description: 'SQL string quoting and commas for use in IN statements and other scenarios', - formatter: ({ value }) => { - // escape single quotes by pairing them - const regExp = new RegExp(`'`, 'g'); - if (isArray(value)) { - return map(value, (v) => `'${replace(v, regExp, "''")}'`).join(','); - } - return `'${replace(value, regExp, "''")}'`; - }, - }, - { - id: FormatRegistryID.date, - name: 'Date', - description: 'Format date in different ways', - formatter: ({ value, args }) => { - const arg = args[0] ?? 'iso'; - - switch (arg) { - case 'ms': - return value; - case 'seconds': - return `${Math.round(parseInt(value, 10)! / 1000)}`; - case 'iso': - return dateTime(parseInt(value, 10)).toISOString(); - default: - return dateTime(parseInt(value, 10)).format(arg); - } - }, - }, - { - id: FormatRegistryID.glob, - name: 'Glob', - description: 'Format multi-valued variables using glob syntax, example {value1,value2}', - formatter: ({ value }) => { - if (isArray(value) && value.length > 1) { - return '{' + value.join(',') + '}'; - } - return value; - }, - }, - { - id: FormatRegistryID.text, - name: 'Text', - description: 'Format variables in their text representation. Example in multi-variable scenario A + B + C.', - formatter: (options, variable) => { - if (typeof options.text === 'string') { - return options.value === ALL_VARIABLE_VALUE ? ALL_VARIABLE_TEXT : options.text; - } - - const current = (variable as any)?.current; - - if (!current) { - return options.value; - } - - return formatVariableLabel(variable); - }, - }, - { - id: FormatRegistryID.queryParam, - name: 'Query parameter', - description: - 'Format variables as URL parameters. Example in multi-variable scenario A + B + C => var-foo=A&var-foo=B&var-foo=C.', - formatter: (options, variable) => { - const { value } = options; - const { name } = variable; - - if (Array.isArray(value)) { - return value.map((v) => formatQueryParameter(name, v)).join('&'); - } - - return formatQueryParameter(name, value); - }, - }, - ]; - - return formats; -}); - -function luceneEscape(value: string) { - if (isNaN(+value) === false) { - return value; - } - - return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1'); -} - -/** - * encode string according to RFC 3986; in contrast to encodeURIComponent() - * also the sub-delims "!", "'", "(", ")" and "*" are encoded; - * unicode handling uses UTF-8 as in ECMA-262. - */ -function encodeURIComponentStrict(str: string) { - return encodeURIComponent(str).replace(/[!'()*]/g, (c) => { - return '%' + c.charCodeAt(0).toString(16).toUpperCase(); - }); -} - -function formatQueryParameter(name: string, value: string): string { - return `var-${name}=${encodeURIComponentStrict(value)}`; -} - -export function isAllValue(value: any) { - return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); -} diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index 25e9528ab35..d0f18300f7d 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -4,13 +4,12 @@ import { setDataSourceSrv } from '@grafana/runtime'; import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +import { FormatRegistryID } from '../scenes/variables/interpolation/formatRegistry'; import { VariableAdapter, variableAdapters } from '../variables/adapters'; import { createAdHocVariableAdapter } from '../variables/adhoc/adapter'; import { createQueryVariableAdapter } from '../variables/query/adapter'; import { VariableModel } from '../variables/types'; -import { FormatRegistryID } from './formatRegistry'; - const key = 'key'; variableAdapters.setInit(() => [ diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index b282b9d9593..2ed9730600a 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -10,13 +10,14 @@ import { } from '@grafana/data'; import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime'; +import { formatRegistry, FormatRegistryID } from '../scenes/variables/interpolation/formatRegistry'; import { variableAdapters } from '../variables/adapters'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants'; import { isAdHoc } from '../variables/guard'; import { getFilteredVariables, getVariables, getVariableWithName } from '../variables/state/selectors'; import { variableRegex } from '../variables/utils'; -import { FormatOptions, formatRegistry, FormatRegistryID } from './formatRegistry'; +import { getVariableWrapper } from './LegacyVariableWrapper'; interface FieldAccessorCache { [key: string]: (obj: any) => any; @@ -38,7 +39,7 @@ export class TemplateSrv implements BaseTemplateSrv { private _variables: any[]; private regex = variableRegex; private index: any = {}; - private grafanaVariables: any = {}; + private grafanaVariables = new Map(); private timeRange?: TimeRange | null = null; private fieldAccessorCache: FieldAccessorCache = {}; @@ -165,12 +166,12 @@ export class TemplateSrv implements BaseTemplateSrv { formatItem = formatRegistry.get(FormatRegistryID.glob); } - const options: FormatOptions = { value, args, text: text ?? value }; - return formatItem.formatter(options, variable); + const formatVariable = getVariableWrapper(variable.name, value, text ?? value); + return formatItem.formatter(value, args, formatVariable); } setGrafanaVariable(name: string, value: any) { - this.grafanaVariables[name] = value; + this.grafanaVariables.set(name, value); } /** @@ -306,7 +307,7 @@ export class TemplateSrv implements BaseTemplateSrv { return this.formatValue(value, fmt, variable, text); } - const systemValue = this.grafanaVariables[variable.current.value]; + const systemValue = this.grafanaVariables.get(variable.current.value); if (systemValue) { return this.formatValue(systemValue, fmt, variable); } diff --git a/public/app/plugins/datasource/postgres/PostgresQueryModel.ts b/public/app/plugins/datasource/postgres/PostgresQueryModel.ts index 90e28e27b1c..eaf5262e54a 100644 --- a/public/app/plugins/datasource/postgres/PostgresQueryModel.ts +++ b/public/app/plugins/datasource/postgres/PostgresQueryModel.ts @@ -2,7 +2,7 @@ import { ScopedVars } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; import { applyQueryDefaults } from 'app/features/plugins/sql/defaults'; import { SQLQuery, SqlQueryModel } from 'app/features/plugins/sql/types'; -import { FormatRegistryID } from 'app/features/templating/formatRegistry'; +import { FormatRegistryID } from 'app/features/scenes/variables/interpolation/formatRegistry'; export class PostgresQueryModel implements SqlQueryModel { target: SQLQuery;