diff --git a/.betterer.results b/.betterer.results index 1fc64aae1b9..f2d9ecde4c6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3089,8 +3089,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/features/dashboard/state/PanelModel.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3873,6 +3872,14 @@ 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/formatVariableValue.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, "Unexpected any. Specify a different type.", "2"] + ], + "public/app/features/templating/macroRegistry.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/templating/template_srv.mock.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -3891,12 +3898,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "12"], [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], + [0, 0, 0, "Do not use any type assertions.", "14"], [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Do not use any type assertions.", "17"], - [0, 0, 0, "Unexpected any. Specify a different type.", "18"], - [0, 0, 0, "Do not use any type assertions.", "19"] + [0, 0, 0, "Do not use any type assertions.", "16"] ], "public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/devenv/dev-dashboards/feature-templating/datadata-macros.json b/devenv/dev-dashboards/feature-templating/datadata-macros.json new file mode 100644 index 00000000000..d662d2f7add --- /dev/null +++ b/devenv/dev-dashboards/feature-templating/datadata-macros.json @@ -0,0 +1,871 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1267, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 8, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "* `__all_variables`=${__all_variables}\n* `__url_time_range`=${__url_time_range}", + "mode": "markdown" + }, + "pluginVersion": "9.5.0-pre", + "title": "Panel Title", + "type": "text" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "links": [ + { + "targetBlank": true, + "title": "value=${__value.raw}&time=${__value.time}&__value:percentencode=${__value:percentencode}&text=${__value.text}", + "url": "value=${__value.raw}&time=${__value.time}justvalue=${__value:percentencode}&text=${__value.text}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 3 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "showRowNums": false + }, + "pluginVersion": "9.5.0-pre", + "title": "DataLink: with __value.raw=&__value.time=&__value:percentencode=", + "type": "table" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "links": [ + { + "targetBlank": true, + "title": "Value link", + "url": "value=${__value.raw}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 3 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 5 + } + ], + "title": "Stat panel with __value.raw ", + "type": "stat" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "links": [ + { + "title": "${__value.raw}", + "url": "${__value.raw}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 6, + "options": { + "displayMode": "basic", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "horizontal", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": true + }, + "showUnfilled": true, + "valueMode": "color" + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "csvFileName": "browser_marketshare.csv", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "data link __value.raw", + "transformations": [ + { + "id": "limit", + "options": { + "limitField": 5 + } + } + ], + "type": "bargauge" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "Since this is using getFrameDisplayName it works kind badly (especially with testdata) and only returns the `Series (refId)`. \n\nSo this should show:\n* Series (Query1)\n* Series (Query2)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "${__series.name}", + "links": [ + { + "targetBlank": true, + "title": "Value link", + "url": "value=${__calc}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "alias": "", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "Query1", + "scenarioId": "random_walk", + "seriesCount": 1 + }, + { + "alias": "", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "Query2", + "scenarioId": "random_walk", + "seriesCount": 1 + } + ], + "title": "${series.name} in display name", + "type": "stat" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "links": [ + { + "title": "__data.refId=${__data.refId}&__data.fields[0]=${__data.fields[0]}&cluster=${__field.labels.cluster}", + "url": "refId=${__data.refId}&__data.fields[0]=${__data.fields[0]}&cluster=${__field.labels.cluster}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 11, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "showRowNums": false + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "labels": "cluster=US", + "refId": "A", + "scenarioId": "random_walk" + } + ], + "title": "DataLink: refId=${__data.refId}&__data.fields[0]=${__data.fields[0]}&cluster=${__field.labels.cluster}", + "type": "table" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [ + { + "title": "${__value.raw}", + "url": "${__value.raw}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "alias": "10,20,30,40", + "csvContent": "Time, value, test\n\"2023-03-24T17:12:12.347Z\", 10,hello\n\"2023-03-24T17:22:12.347Z\", 20,asd\n\"2023-03-24T17:32:12.347Z\", 30,asd2\n\"2023-03-24T17:42:12.347Z\", 40,as34\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "alias": "5,6,7", + "csvContent": "Time, value, test\n\"2023-03-24T17:12:12.347Z\", 5,hello\n\"2023-03-24T17:22:12.347Z\", 6,asd\n\"2023-03-24T17:42:12.347Z\", 7,as34\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Data links with ${__value.raw}", + "transformations": [ + { + "id": "joinByField", + "options": {} + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "links": [ + { + "title": "__field.name=${__field.name}&__field.labels.cluster=${__field.labels.cluster}", + "url": "__field.name=${__field.name}&__field.labels.cluster=${__field.labels.cluster}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 13, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "showRowNums": false + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "labels": "cluster=US", + "refId": "A", + "scenarioId": "random_walk" + } + ], + "title": "DataLink: __field.name=&__field.labels.cluster", + "type": "table" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "The stat display names should be \n* Stockholm = Bad\n* New York = Good \n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "$__cell_0 = $__cell_2", + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 14, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "alias": "", + "csvContent": "name, value, name2\nStockholm, 10, Good\nNew York, 15, Bad", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "DisplayName with __cell_0 = __cell_2", + "type": "stat" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "The stat display names should be \n* Stockholm = Bad\n* New York = Good \n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "displayName": "${__field.name}", + "links": [ + { + "targetBlank": true, + "title": "Value link", + "url": "value=${__calc}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 31 + }, + "id": 15, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "showRowNums": false + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "alias": "", + "csvContent": "name, value, name2\nStockholm, 10, Good\nNew York, 15, Bad", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "DisplayName: __field.name", + "type": "table" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "The stat display names should be \n* Stockholm = Bad\n* New York = Good \n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "${__data.fields[0]} = ${__data.fields[2]}", + "links": [ + { + "targetBlank": true, + "title": "__data.fields[0] = ${__data.fields[0]} = __value.raw = ${__value.raw}", + "url": "__data.fields[0] = ${__data.fields[0]} = __value.raw = ${__value.raw}" + } + ], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 31 + }, + "id": 16, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.0-pre", + "targets": [ + { + "alias": "", + "csvContent": "name, value, name2\nStockholm, 10, Good\nNew York, 15, Bad", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "$__data.fields[0] = $__data.fields[2] with datalinks", + "type": "stat" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": ["gdev", "templating"], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "A", + "value": "A" + }, + "hide": 0, + "includeAll": false, + "multi": false, + "name": "customVar", + "options": [ + { + "selected": true, + "text": "A", + "value": "A" + }, + { + "selected": false, + "text": "B", + "value": "B" + }, + { + "selected": false, + "text": "C", + "value": "C" + } + ], + "query": "A,B,C", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "2023-03-24T17:12:12.347Z", + "to": "2023-03-24T17:42:12.347Z" + }, + "timepicker": {}, + "timezone": "", + "title": "Templating - Macros", + "uid": "e7c29343-6d1e-4167-9c13-803fe5be8c46", + "version": 48, + "weekStart": "" + } \ No newline at end of file diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index dbae311f9ce..ff4d81e4622 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -149,6 +149,13 @@ local dashboard = grafana.dashboard; id: 0, } }, + dashboard.new('datadata-macros', import '../dev-dashboards/feature-templating/datadata-macros.json') + + resource.addMetadata('folder', 'dev-dashboards') + + { + spec+: { + id: 0, + } + }, dashboard.new('demo1', import '../dev-dashboards/datasource-testdata/demo1.json') + resource.addMetadata('folder', 'dev-dashboards') + { diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 2321c1a621b..59abb84e52f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -329,7 +329,7 @@ describe('applyFieldOverrides', () => { data.fields[1].getLinks!({ valueRowIndex: 0 }); expect(data.fields[1].config.decimals).toEqual(1); - expect(replaceVariablesCalls[3].__value.value.text).toEqual('100.0'); + expect(replaceVariablesCalls[3].__dataContext?.value.rowIndex).toEqual(0); }); it('creates a deep clone of field config', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 9408547b269..ceceb40952d 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -5,13 +5,13 @@ import usePrevious from 'react-use/lib/usePrevious'; import { VariableFormatID } from '@grafana/schema'; import { compareArrayValues, compareDataFrameStructures, guessFieldTypeForField } from '../dataframe'; -import { getTimeField } from '../dataframe/processDataFrame'; import { PanelPlugin } from '../panel/PanelPlugin'; import { GrafanaTheme2 } from '../themes'; import { asHexString } from '../themes/colorManipulator'; import { fieldMatchers, reduceField, ReducerID } from '../transformations'; import { ApplyFieldOverrideOptions, + DataContextScopedVar, DataFrame, DataLink, DecimalCount, @@ -34,9 +34,8 @@ import { ValueLinkConfig, } from '../types'; import { FieldMatcher } from '../types/transformations'; -import { DataLinkBuiltInVars, locationUtil } from '../utils'; +import { locationUtil } from '../utils'; import { mapInternalLinkToExplore } from '../utils/dataLinks'; -import { formattedValueToString } from '../valueFormats'; import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; @@ -370,29 +369,21 @@ export const getLinksSupplier = if (!field.config.links || field.config.links.length === 0) { return []; } - const timeRangeUrl = locationUtil.getTimeRangeUrlParams(); - const { timeField } = getTimeField(frame); return field.config.links.map((link: DataLink) => { - const variablesQuery = locationUtil.getVariablesUrlParams(); let dataFrameVars = {}; - let valueVars = {}; + let dataContext: DataContextScopedVar = { value: { frame, field } }; // We are not displaying reduction result if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) { + dataContext.value.rowIndex = config.valueRowIndex; + const fieldsProxy = getFieldDisplayValuesProxy({ frame, rowIndex: config.valueRowIndex, timeZone: timeZone, }); - valueVars = { - raw: field.values.get(config.valueRowIndex), - numeric: fieldsProxy[field.name].numeric, - text: fieldsProxy[field.name].text, - time: timeField ? timeField.values.get(config.valueRowIndex) : undefined, - }; - dataFrameVars = { __data: { value: { @@ -404,32 +395,13 @@ export const getLinksSupplier = }, }; } else { - if (config.calculatedValue) { - valueVars = { - raw: config.calculatedValue.numeric, - numeric: config.calculatedValue.numeric, - text: formattedValueToString(config.calculatedValue), - }; - } + dataContext.value.calculatedValue = config.calculatedValue; } const variables: ScopedVars = { ...fieldScopedVars, - __value: { - text: 'Value', - value: valueVars, - }, ...dataFrameVars, - [DataLinkBuiltInVars.keepTime]: { - text: timeRangeUrl, - value: timeRangeUrl, - skipFormat: true, - }, - [DataLinkBuiltInVars.includeVars]: { - text: variablesQuery, - value: variablesQuery, - skipFormat: true, - }, + __dataContext: dataContext, }; if (link.onClick) { diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.test.ts b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts index d792a07e316..8317e827d0d 100644 --- a/packages/grafana-data/src/transformations/transformers/calculateField.test.ts +++ b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts @@ -273,7 +273,7 @@ describe('calculateField transformer w/ timeseries', () => { }; for (const key of Object.keys(variables)) { if (target === `$${key}`) { - return variables[key].value + ''; + return variables[key]!.value + ''; } } return target; diff --git a/packages/grafana-data/src/transformations/transformers/filterByName.test.ts b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts index 554864069d1..2b59037b61d 100644 --- a/packages/grafana-data/src/transformations/transformers/filterByName.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts @@ -219,7 +219,7 @@ describe('filterByName transformer', () => { }, }; for (const key of Object.keys(variables)) { - return target.replace(`$${key}`, variables[key].value); + return target.replace(`$${key}`, variables[key]!.value); } return target; }, diff --git a/packages/grafana-data/src/types/ScopedVars.ts b/packages/grafana-data/src/types/ScopedVars.ts index 685e6e0ca62..b5e6b9e0ed7 100644 --- a/packages/grafana-data/src/types/ScopedVars.ts +++ b/packages/grafana-data/src/types/ScopedVars.ts @@ -1,8 +1,24 @@ +import { DataFrame, Field } from './dataFrame'; +import { DisplayValue } from './displayValue'; + export interface ScopedVar { text?: any; value: T; - skipUrlSync?: boolean; - skipFormat?: boolean; } -export interface ScopedVars extends Record {} +export interface ScopedVars { + __dataContext?: DataContextScopedVar; + [key: string]: ScopedVar | undefined; +} + +/** + * Used by data link macros + */ +export interface DataContextScopedVar { + value: { + frame: DataFrame; + field: Field; + rowIndex?: number; + calculatedValue?: DisplayValue; + }; +} diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts index 89784452605..1729fb0fa53 100644 --- a/packages/grafana-data/src/utils/dataLinks.test.ts +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -114,7 +114,7 @@ describe('mapInternalLinkToExplore', () => { config: {}, values: new ArrayVector([2]), }, - replaceVariables: (val, scopedVars) => val.replace(/\$var/g, scopedVars!['var1'].value), + replaceVariables: (val, scopedVars) => val.replace(/\$var/g, scopedVars!['var1']!.value), }); expect(decodeURIComponent(link.href)).toEqual( diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index 6e13298bb18..b54f32fa4ea 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -65,7 +65,7 @@ function getDefaultDataFrame(): DataFrame { overrides: [], }, replaceVariables: (value, vars, format) => { - return vars && value === '${__value.text}' ? vars['__value'].value.text : value; + return vars && value === '${__value.text}' ? '${__value.text} interpolation' : value; }, timeZone: 'utc', theme: createTheme(), @@ -144,10 +144,10 @@ describe('Table', () => { const rows = within(getTable()).getAllByRole('row'); expect(rows).toHaveLength(5); expect(getRowsData(rows)).toEqual([ - { time: '2021-01-01 00:00:00', temperature: '10', link: '10' }, - { time: '2021-01-01 03:00:00', temperature: 'NaN', link: 'NaN' }, - { time: '2021-01-01 01:00:00', temperature: '11', link: '11' }, - { time: '2021-01-01 02:00:00', temperature: '12', link: '12' }, + { time: '2021-01-01 00:00:00', temperature: '10', link: '${__value.text} interpolation' }, + { time: '2021-01-01 03:00:00', temperature: 'NaN', link: '${__value.text} interpolation' }, + { time: '2021-01-01 01:00:00', temperature: '11', link: '${__value.text} interpolation' }, + { time: '2021-01-01 02:00:00', temperature: '12', link: '${__value.text} interpolation' }, ]); }); }); @@ -203,10 +203,10 @@ describe('Table', () => { const rows = within(getTable()).getAllByRole('row'); expect(rows).toHaveLength(5); expect(getRowsData(rows)).toEqual([ - { time: '2021-01-01 02:00:00', temperature: '12', link: '12' }, - { time: '2021-01-01 01:00:00', temperature: '11', link: '11' }, - { time: '2021-01-01 00:00:00', temperature: '10', link: '10' }, - { time: '2021-01-01 03:00:00', temperature: 'NaN', link: 'NaN' }, + { time: '2021-01-01 02:00:00', temperature: '12', link: '${__value.text} interpolation' }, + { time: '2021-01-01 01:00:00', temperature: '11', link: '${__value.text} interpolation' }, + { time: '2021-01-01 00:00:00', temperature: '10', link: '${__value.text} interpolation' }, + { time: '2021-01-01 03:00:00', temperature: 'NaN', link: '${__value.text} interpolation' }, ]); }); }); @@ -582,10 +582,10 @@ describe('Table', () => { const rows = within(getTable()).getAllByRole('row'); expect(rows).toHaveLength(5); expect(getRowsData(rows)).toEqual([ - { time: '2021-01-01 00:00:00', temperature: '10', link: '10' }, - { time: '2021-01-01 03:00:00', temperature: 'NaN', link: 'NaN' }, - { time: '2021-01-01 01:00:00', temperature: '11', link: '11' }, - { time: '2021-01-01 02:00:00', temperature: '12', link: '12' }, + { time: '2021-01-01 00:00:00', temperature: '10', link: '${__value.text} interpolation' }, + { time: '2021-01-01 03:00:00', temperature: 'NaN', link: '${__value.text} interpolation' }, + { time: '2021-01-01 01:00:00', temperature: '11', link: '${__value.text} interpolation' }, + { time: '2021-01-01 02:00:00', temperature: '12', link: '${__value.text} interpolation' }, ]); await userEvent.click(within(rows[1]).getByLabelText('Expand row')); diff --git a/public/app/core/logsModel.ts b/public/app/core/logsModel.ts index eb85839b55b..f1b5ea7a980 100644 --- a/public/app/core/logsModel.ts +++ b/public/app/core/logsModel.ts @@ -784,7 +784,7 @@ export function queryLogsSample { model.processRepeats(); expect(model.panels.filter((x) => x.type === 'row')).toHaveLength(2); expect(model.panels.filter((x) => x.type !== 'row')).toHaveLength(4); - expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.dc.value).toBe('dc1'); - expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.app.value).toBe('se1'); + expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.dc?.value).toBe('dc1'); + expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.app?.value).toBe('se1'); const saveModel = model.getSaveModelClone(); expect(saveModel.panels.length).toBe(2); @@ -697,15 +697,15 @@ describe('DashboardModel', () => { model.processRepeats(); expect(model.panels.filter((x) => x.type === 'row')).toHaveLength(2); expect(model.panels.filter((x) => x.type !== 'row')).toHaveLength(4); - expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.dc.value).toBe('dc1'); - expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.app.value).toBe('se1'); + expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.dc?.value).toBe('dc1'); + expect(model.panels.find((x) => x.type !== 'row')?.scopedVars?.app?.value).toBe('se1'); model.snapshot = { timestamp: new Date() }; const saveModel = model.getSaveModelClone(); expect(saveModel.panels.filter((x) => x.type === 'row')).toHaveLength(2); expect(saveModel.panels.filter((x) => x.type !== 'row')).toHaveLength(4); - expect(saveModel.panels.find((x) => x.type !== 'row')?.scopedVars?.dc.value).toBe('dc1'); - expect(saveModel.panels.find((x) => x.type !== 'row')?.scopedVars?.app.value).toBe('se1'); + expect(saveModel.panels.find((x) => x.type !== 'row')?.scopedVars?.dc?.value).toBe('dc1'); + expect(saveModel.panels.find((x) => x.type !== 'row')?.scopedVars?.app?.value).toBe('se1'); model.collapseRows(); const savedModelWithCollapsedRows = model.getSaveModelClone(); diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index d45d097f438..70aa401a1bd 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -1,7 +1,6 @@ import { ComponentClass } from 'react'; import { - DataLinkBuiltInVars, FieldConfigProperty, PanelData, PanelProps, @@ -19,7 +18,6 @@ import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; import { TemplateSrv } from '../../templating/template_srv'; import { variableAdapters } from '../../variables/adapters'; import { createQueryVariableAdapter } from '../../variables/query/adapter'; -import { setTimeSrv } from '../services/TimeSrv'; import { TimeOverrideResult } from '../utils/panel'; import { PanelModel } from './PanelModel'; @@ -27,13 +25,6 @@ import { PanelModel } from './PanelModel'; standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); -setTimeSrv({ - timeRangeForUrl: () => ({ - from: 1607687293000, - to: 1607687293100, - }), -} as any); - const getVariables = () => variablesMock; const getVariableWithName = (name: string) => variablesMock.filter((v) => v.name === name)[0]; const getFilteredVariables = jest.fn(); @@ -211,21 +202,12 @@ describe('PanelModel', () => { bbb: { value: 'BBB', text: 'upperB' }, }; }); + it('should interpolate variables', () => { const out = model.replaceVariables('hello $aaa'); expect(out).toBe('hello AAA'); }); - it('should interpolate $__url_time_range variable', () => { - const out = model.replaceVariables(`/d/1?$${DataLinkBuiltInVars.keepTime}`); - expect(out).toBe('/d/1?from=1607687293000&to=1607687293100'); - }); - - it('should interpolate $__all_variables variable', () => { - const out = model.replaceVariables(`/d/1?$${DataLinkBuiltInVars.includeVars}`); - expect(out).toBe('/d/1?var-test1=val1&var-test2=val2&var-test3=Value%203&var-test4=A&var-test4=B'); - }); - it('should prefer the local variable value', () => { const extra = { aaa: { text: '???', value: 'XXX' } }; const out = model.replaceVariables('hello $aaa and $bbb', extra); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index dfdc8578c86..ecda98f0cc1 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -5,7 +5,6 @@ import { DataConfigSource, DataFrameDTO, DataLink, - DataLinkBuiltInVars, DataQuery, DataTransformerConfig, EventBusSrv, @@ -13,7 +12,6 @@ import { PanelPlugin, PanelPluginDataSupport, ScopedVars, - urlUtil, PanelModel as IPanelModel, DataSourceRef, CoreApp, @@ -36,8 +34,6 @@ import { } from 'app/types/events'; import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; -import { getVariablesUrlParams } from '../../variables/getAllVariableValuesForUrl'; -import { getTimeSrv } from '../services/TimeSrv'; import { TimeOverrideResult } from '../utils/panel'; export interface GridPos { @@ -639,23 +635,6 @@ export class PanelModel implements DataConfigSource, IPanelModel { replaceVariables(value: string, extraVars: ScopedVars | undefined, format?: string | Function) { const lastRequest = this.getQueryRunner().getLastRequest(); const vars: ScopedVars = Object.assign({}, this.scopedVars, lastRequest?.scopedVars, extraVars); - - const allVariablesParams = getVariablesUrlParams(vars); - const variablesQuery = urlUtil.toUrlParams(allVariablesParams); - const timeRangeUrl = urlUtil.toUrlParams(getTimeSrv().timeRangeForUrl()); - - vars[DataLinkBuiltInVars.keepTime] = { - text: timeRangeUrl, - value: timeRangeUrl, - skipFormat: true, - }; - - vars[DataLinkBuiltInVars.includeVars] = { - text: variablesQuery, - value: variablesQuery, - skipFormat: true, - }; - return getTemplateSrv().replace(value, vars, format); } diff --git a/public/app/features/query/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts index 4422a8e2b23..971ad89166c 100644 --- a/public/app/features/query/state/PanelQueryRunner.test.ts +++ b/public/app/features/query/state/PanelQueryRunner.test.ts @@ -168,9 +168,9 @@ describe('PanelQueryRunner', () => { }); it('should pass scopedVars to datasource with interval props', async () => { - expect(ctx.queryCalledWith?.scopedVars.server.text).toBe('Server1'); - expect(ctx.queryCalledWith?.scopedVars.__interval.text).toBe('5m'); - expect(ctx.queryCalledWith?.scopedVars.__interval_ms.text).toBe('300000'); + expect(ctx.queryCalledWith?.scopedVars.server!.text).toBe('Server1'); + expect(ctx.queryCalledWith?.scopedVars.__interval!.text).toBe('5m'); + expect(ctx.queryCalledWith?.scopedVars.__interval_ms!.text).toBe('300000'); }); }); diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx index 9635bcffce6..433c7e65926 100644 --- a/public/app/features/scenes/dashboard/DashboardScene.tsx +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -26,24 +26,15 @@ export class DashboardScene extends SceneObjectBase { public static Component = DashboardSceneRenderer; private urlSyncManager?: UrlSyncManager; - public activate() { - super.activate(); - } - /** * It's better to do this before activate / mount to not trigger unnessary re-renders */ public initUrlSync() { - this.urlSyncManager = new UrlSyncManager(this); - this.urlSyncManager.initSync(); - } - - public deactivate() { - super.deactivate(); - - if (this.urlSyncManager) { - this.urlSyncManager!.cleanUp(); + if (!this.urlSyncManager) { + this.urlSyncManager = new UrlSyncManager(this); } + + this.urlSyncManager.initSync(); } } diff --git a/public/app/features/search/page/components/SearchResultsTable.test.tsx b/public/app/features/search/page/components/SearchResultsTable.test.tsx index eba70c2b4d1..734fd824188 100644 --- a/public/app/features/search/page/components/SearchResultsTable.test.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.test.tsx @@ -45,7 +45,7 @@ describe('SearchResultsTable', () => { overrides: [], }, replaceVariables: (value, vars, format) => { - return vars && value === '${__value.text}' ? vars['__value'].value.text : value; + return vars && value === '${__value.text}' ? vars['__value']!.value.text : value; }, theme: createTheme(), }); diff --git a/public/app/features/templating/dataMacros.test.ts b/public/app/features/templating/dataMacros.test.ts new file mode 100644 index 00000000000..164a5c8b345 --- /dev/null +++ b/public/app/features/templating/dataMacros.test.ts @@ -0,0 +1,86 @@ +import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; + +import { DataContextScopedVar, FieldType, toDataFrame } from '@grafana/data'; +import { TemplateSrv } from '@grafana/runtime'; + +describe('templateSrv', () => { + let _templateSrv: TemplateSrv; + + beforeEach(() => { + _templateSrv = initTemplateSrv('hello', []); + }); + + const data = toDataFrame({ + name: 'A', + fields: [ + { + name: 'number', + type: FieldType.number, + values: [5, 10], + display: (value: number) => { + return { text: value.toString(), numeric: value, suffix: '%' }; + }, + }, + { + name: 'time', + type: FieldType.time, + values: [5000, 10000], + }, + ], + }); + + it('Should interpolate __value.* expressions with dataContext in scopedVars', () => { + const dataContext: DataContextScopedVar = { + value: { + frame: data, + field: data.fields[0], + rowIndex: 1, + }, + }; + + const scopedVars = { __dataContext: dataContext }; + + expect(_templateSrv.replace('${__value.raw}', scopedVars)).toBe('10'); + expect(_templateSrv.replace('${__value.numeric}', scopedVars)).toBe('10'); + expect(_templateSrv.replace('${__value}', scopedVars)).toBe('10%'); + expect(_templateSrv.replace('${__value.text}', scopedVars)).toBe('10'); + expect(_templateSrv.replace('${__value.time}', scopedVars)).toBe('10000'); + // can apply format as well + expect(_templateSrv.replace('${__value:percentencode}', scopedVars)).toBe('10%25'); + }); + + it('Should interpolate __value.* with calculatedValue', () => { + const dataContext: DataContextScopedVar = { + value: { + frame: data, + field: data.fields[0], + calculatedValue: { + text: '15', + numeric: 15, + suffix: '%', + }, + }, + }; + + const scopedVars = { __dataContext: dataContext }; + + expect(_templateSrv.replace('${__value.raw}', scopedVars)).toBe('15'); + expect(_templateSrv.replace('${__value.numeric}', scopedVars)).toBe('15'); + expect(_templateSrv.replace('${__value}', scopedVars)).toBe('15%'); + expect(_templateSrv.replace('${__value.text}', scopedVars)).toBe('15%'); + expect(_templateSrv.replace('${__value.time}', scopedVars)).toBe(''); + }); + + it('Should return match when ${__value.*} is used and no dataContext or rowIndex is found', () => { + const dataContext: DataContextScopedVar = { + value: { + frame: data, + field: data.fields[0], + }, + }; + + const scopedVars = { __dataContext: dataContext }; + + expect(_templateSrv.replace('${__value.raw}', scopedVars)).toBe('${__value.raw}'); + }); +}); diff --git a/public/app/features/templating/dataMacros.ts b/public/app/features/templating/dataMacros.ts new file mode 100644 index 00000000000..354c1b58f42 --- /dev/null +++ b/public/app/features/templating/dataMacros.ts @@ -0,0 +1,76 @@ +import { DisplayProcessor, FieldType, formattedValueToString, getDisplayProcessor, ScopedVars } from '@grafana/data'; +import { VariableCustomFormatterFn } from '@grafana/scenes'; + +import { formatVariableValue } from './formatVariableValue'; + +/** + * ${__value.raw/nummeric/text/time} macro + */ +export function valueMacro( + match: string, + fieldPath?: string, + scopedVars?: ScopedVars, + format?: string | VariableCustomFormatterFn +) { + const value = getValueForValueMacro(match, fieldPath, scopedVars); + return formatVariableValue(value, format); +} + +function getValueForValueMacro(match: string, fieldPath?: string, scopedVars?: ScopedVars) { + const dataContext = scopedVars?.__dataContext; + if (!dataContext) { + return match; + } + + const { frame, rowIndex, field, calculatedValue } = dataContext.value; + + if (calculatedValue) { + switch (fieldPath) { + case 'numeric': + return calculatedValue.numeric.toString(); + case 'raw': + return calculatedValue.numeric; + case 'time': + return ''; + case 'text': + default: + return formattedValueToString(calculatedValue); + } + } + + if (rowIndex === undefined) { + return match; + } + + if (fieldPath === 'time') { + const timeField = frame.fields.find((f) => f.type === FieldType.time); + return timeField ? timeField.values.get(rowIndex) : undefined; + } + + const value = field.values.get(rowIndex); + if (fieldPath === 'raw') { + return value; + } + + const displayProcessor = field.display ?? getFallbackDisplayProcessor(); + const result = displayProcessor(value); + + switch (fieldPath) { + case 'numeric': + return result.numeric; + case 'text': + return result.text; + default: + return formattedValueToString(result); + } +} + +let fallbackDisplayProcessor: DisplayProcessor | undefined; + +function getFallbackDisplayProcessor() { + if (!fallbackDisplayProcessor) { + fallbackDisplayProcessor = getDisplayProcessor(); + } + + return fallbackDisplayProcessor; +} diff --git a/public/app/features/templating/formatVariableValue.test.ts b/public/app/features/templating/formatVariableValue.test.ts new file mode 100644 index 00000000000..1d355a4d4f8 --- /dev/null +++ b/public/app/features/templating/formatVariableValue.test.ts @@ -0,0 +1,105 @@ +import { silenceConsoleOutput } from 'test/core/utils/silenceConsoleOutput'; + +import { VariableFormatID } from '@grafana/schema'; + +import { formatVariableValue } from './formatVariableValue'; + +describe('format variable to string values', () => { + silenceConsoleOutput(); + + it('single value should return value', () => { + const result = formatVariableValue('test'); + expect(result).toBe('test'); + }); + + it('should use glob format when unknown format provided', () => { + let result = formatVariableValue('test', 'nonexistentformat'); + expect(result).toBe('test'); + result = formatVariableValue(['test', 'test1'], 'nonexistentformat'); + expect(result).toBe('{test,test1}'); + }); + + it('multi value and glob format should render glob string', () => { + const result = formatVariableValue(['test', 'test2'], 'glob'); + expect(result).toBe('{test,test2}'); + }); + + it('multi value and lucene should render as lucene expr', () => { + const result = formatVariableValue(['test', 'test2'], 'lucene'); + expect(result).toBe('("test" OR "test2")'); + }); + + it('multi value and regex format should render regex string', () => { + const result = formatVariableValue(['test.', 'test2'], 'regex'); + expect(result).toBe('(test\\.|test2)'); + }); + + it('multi value and pipe should render pipe string', () => { + const result = formatVariableValue(['test', 'test2'], 'pipe'); + expect(result).toBe('test|test2'); + }); + + it('multi value and distributed should render distributed string', () => { + const result = formatVariableValue(['test', 'test2'], 'distributed', { + name: 'build', + }); + expect(result).toBe('test,build=test2'); + }); + + it('multi value and distributed should render when not string', () => { + const result = formatVariableValue(['test'], 'distributed', { + name: 'build', + }); + expect(result).toBe('test'); + }); + + it('multi value and csv format should render csv string', () => { + const result = formatVariableValue(['test', 'test2'], VariableFormatID.CSV); + expect(result).toBe('test,test2'); + }); + + it('multi value and percentencode format should render percent-encoded string', () => { + const result = formatVariableValue(['foo()bar BAZ', 'test2'], VariableFormatID.PercentEncode); + expect(result).toBe('%7Bfoo%28%29bar%20BAZ%2Ctest2%7D'); + }); + + it('slash should be properly escaped in regex format', () => { + const result = formatVariableValue('Gi3/14', 'regex'); + expect(result).toBe('Gi3\\/14'); + }); + + it('single value and singlequote format should render string with value enclosed in single quotes', () => { + const result = formatVariableValue('test', 'singlequote'); + expect(result).toBe("'test'"); + }); + + it('multi value and singlequote format should render string with values enclosed in single quotes', () => { + const result = formatVariableValue(['test', "test'2"], 'singlequote'); + expect(result).toBe("'test','test\\'2'"); + }); + + it('single value and doublequote format should render string with value enclosed in double quotes', () => { + const result = formatVariableValue('test', 'doublequote'); + expect(result).toBe('"test"'); + }); + + it('multi value and doublequote format should render string with values enclosed in double quotes', () => { + const result = formatVariableValue(['test', 'test"2'], 'doublequote'); + expect(result).toBe('"test","test\\"2"'); + }); + + it('single value and sqlstring format should render string with value enclosed in single quotes', () => { + const result = formatVariableValue("test'value", 'sqlstring'); + expect(result).toBe(`'test''value'`); + }); + + it('multi value and sqlstring format should render string with values enclosed in single quotes', () => { + const result = formatVariableValue(['test', "test'value2"], 'sqlstring'); + expect(result).toBe(`'test','test''value2'`); + }); + + it('raw format should leave value intact and do no escaping', () => { + const result = formatVariableValue("'test\n", 'raw'); + expect(result).toBe("'test\n"); + }); +}); diff --git a/public/app/features/templating/formatVariableValue.ts b/public/app/features/templating/formatVariableValue.ts new file mode 100644 index 00000000000..74a5bf3e1e0 --- /dev/null +++ b/public/app/features/templating/formatVariableValue.ts @@ -0,0 +1,50 @@ +import { formatRegistry, FormatRegistryID } from '@grafana/scenes'; + +import { isAdHoc } from '../variables/guard'; + +import { getVariableWrapper } from './LegacyVariableWrapper'; + +export function formatVariableValue(value: any, format?: any, variable?: any, text?: string): string { + // for some scopedVars there is no variable + variable = variable || {}; + + if (value === null || value === undefined) { + return ''; + } + + if (isAdHoc(variable) && format !== FormatRegistryID.queryParam) { + return ''; + } + + // if it's an object transform value to string + if (!Array.isArray(value) && typeof value === 'object') { + value = `${value}`; + } + + if (typeof format === 'function') { + return format(value, variable, formatVariableValue); + } + + if (!format) { + format = FormatRegistryID.glob; + } + + // some formats have arguments that come after ':' character + let args = format.split(':'); + if (args.length > 1) { + format = args[0]; + args = args.slice(1); + } else { + args = []; + } + + let formatItem = formatRegistry.getIfExists(format); + + if (!formatItem) { + console.error(`Variable format ${format} not found. Using glob format as fallback.`); + formatItem = formatRegistry.get(FormatRegistryID.glob); + } + + const formatVariable = getVariableWrapper(variable, value, text ?? value); + return formatItem.formatter(value, args, formatVariable); +} diff --git a/public/app/features/templating/macroRegistry.test.ts b/public/app/features/templating/macroRegistry.test.ts new file mode 100644 index 00000000000..2e212c5f940 --- /dev/null +++ b/public/app/features/templating/macroRegistry.test.ts @@ -0,0 +1,54 @@ +import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; + +import { DataLinkBuiltInVars } from '@grafana/data'; +import { getTemplateSrv, setTemplateSrv } from '@grafana/runtime'; + +import { setTimeSrv } from '../dashboard/services/TimeSrv'; +import { variableAdapters } from '../variables/adapters'; +import { createQueryVariableAdapter } from '../variables/query/adapter'; + +describe('__all_variables', () => { + beforeAll(() => { + variableAdapters.register(createQueryVariableAdapter()); + + setTemplateSrv( + initTemplateSrv('hello', [ + { + type: 'query', + name: 'test', + rootStateKey: 'hello', + current: { value: ['val1', 'val2'] }, + getValueForUrl: function () { + return this.current.value; + }, + }, + ]) + ); + }); + + it('should interpolate correctly', () => { + const out = getTemplateSrv().replace(`/d/1?$${DataLinkBuiltInVars.includeVars}`); + expect(out).toBe('/d/1?var-test=val1&var-test=val2'); + }); + + it('should interpolate and take scopedVars into account', () => { + const out = getTemplateSrv().replace(`/d/1?$${DataLinkBuiltInVars.includeVars}`, { test: { value: 'val3' } }); + expect(out).toBe('/d/1?var-test=val3'); + }); +}); + +describe('__url_time_range', () => { + beforeAll(() => { + setTimeSrv({ + timeRangeForUrl: () => ({ + from: 1607687293000, + to: 1607687293100, + }), + } as any); + }); + + it('should interpolate to url params', () => { + const out = getTemplateSrv().replace(`/d/1?$${DataLinkBuiltInVars.keepTime}`); + expect(out).toBe('/d/1?from=1607687293000&to=1607687293100'); + }); +}); diff --git a/public/app/features/templating/macroRegistry.ts b/public/app/features/templating/macroRegistry.ts new file mode 100644 index 00000000000..8431ef6c154 --- /dev/null +++ b/public/app/features/templating/macroRegistry.ts @@ -0,0 +1,22 @@ +import { DataLinkBuiltInVars, ScopedVars, urlUtil } from '@grafana/data'; + +import { getTimeSrv } from '../dashboard/services/TimeSrv'; +import { getVariablesUrlParams } from '../variables/getAllVariableValuesForUrl'; + +import { valueMacro } from './dataMacros'; +import { MacroHandler } from './types'; + +export const macroRegistry: Record = { + ['__value']: valueMacro, + [DataLinkBuiltInVars.includeVars]: includeVarsMacro, + [DataLinkBuiltInVars.keepTime]: urlTimeRangeMacro, +}; + +function includeVarsMacro(match: string, fieldPath?: string, scopedVars?: ScopedVars) { + const allVariablesParams = getVariablesUrlParams(scopedVars); + return urlUtil.toUrlParams(allVariablesParams); +} + +function urlTimeRangeMacro() { + return urlUtil.toUrlParams(getTimeSrv().timeRangeForUrl()); +} diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index e5ade0b6c1b..11b80ff841a 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -1,7 +1,6 @@ import { dateTime, TimeRange } from '@grafana/data'; import { setDataSourceSrv, VariableInterpolation } from '@grafana/runtime'; import { FormatRegistryID, TestVariable } from '@grafana/scenes'; -import { VariableFormatID } from '@grafana/schema'; import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; @@ -424,104 +423,6 @@ describe('templateSrv', () => { }); }); - describe('format variable to string values', () => { - it('single value should return value', () => { - const result = _templateSrv.formatValue('test'); - expect(result).toBe('test'); - }); - - it('should use glob format when unknown format provided', () => { - let result = _templateSrv.formatValue('test', 'nonexistentformat'); - expect(result).toBe('test'); - result = _templateSrv.formatValue(['test', 'test1'], 'nonexistentformat'); - expect(result).toBe('{test,test1}'); - }); - - it('multi value and glob format should render glob string', () => { - const result = _templateSrv.formatValue(['test', 'test2'], 'glob'); - expect(result).toBe('{test,test2}'); - }); - - it('multi value and lucene should render as lucene expr', () => { - const result = _templateSrv.formatValue(['test', 'test2'], 'lucene'); - expect(result).toBe('("test" OR "test2")'); - }); - - it('multi value and regex format should render regex string', () => { - const result = _templateSrv.formatValue(['test.', 'test2'], 'regex'); - expect(result).toBe('(test\\.|test2)'); - }); - - it('multi value and pipe should render pipe string', () => { - const result = _templateSrv.formatValue(['test', 'test2'], 'pipe'); - expect(result).toBe('test|test2'); - }); - - it('multi value and distributed should render distributed string', () => { - const result = _templateSrv.formatValue(['test', 'test2'], 'distributed', { - name: 'build', - }); - expect(result).toBe('test,build=test2'); - }); - - it('multi value and distributed should render when not string', () => { - const result = _templateSrv.formatValue(['test'], 'distributed', { - name: 'build', - }); - expect(result).toBe('test'); - }); - - it('multi value and csv format should render csv string', () => { - const result = _templateSrv.formatValue(['test', 'test2'], VariableFormatID.CSV); - expect(result).toBe('test,test2'); - }); - - it('multi value and percentencode format should render percent-encoded string', () => { - const result = _templateSrv.formatValue(['foo()bar BAZ', 'test2'], VariableFormatID.PercentEncode); - expect(result).toBe('%7Bfoo%28%29bar%20BAZ%2Ctest2%7D'); - }); - - it('slash should be properly escaped in regex format', () => { - const result = _templateSrv.formatValue('Gi3/14', 'regex'); - expect(result).toBe('Gi3\\/14'); - }); - - it('single value and singlequote format should render string with value enclosed in single quotes', () => { - const result = _templateSrv.formatValue('test', 'singlequote'); - expect(result).toBe("'test'"); - }); - - it('multi value and singlequote format should render string with values enclosed in single quotes', () => { - const result = _templateSrv.formatValue(['test', "test'2"], 'singlequote'); - expect(result).toBe("'test','test\\'2'"); - }); - - it('single value and doublequote format should render string with value enclosed in double quotes', () => { - const result = _templateSrv.formatValue('test', 'doublequote'); - expect(result).toBe('"test"'); - }); - - it('multi value and doublequote format should render string with values enclosed in double quotes', () => { - const result = _templateSrv.formatValue(['test', 'test"2'], 'doublequote'); - expect(result).toBe('"test","test\\"2"'); - }); - - it('single value and sqlstring format should render string with value enclosed in single quotes', () => { - const result = _templateSrv.formatValue("test'value", 'sqlstring'); - expect(result).toBe(`'test''value'`); - }); - - it('multi value and sqlstring format should render string with values enclosed in single quotes', () => { - const result = _templateSrv.formatValue(['test', "test'value2"], 'sqlstring'); - expect(result).toBe(`'test','test''value2'`); - }); - - it('raw format should leave value intact and do no escaping', () => { - const result = _templateSrv.formatValue("'test\n", 'raw'); - expect(result).toBe("'test\n"); - }); - }); - describe('can check if variable exists', () => { beforeEach(() => { _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); @@ -747,6 +648,7 @@ describe('templateSrv', () => { let passedValue: string | null = null; _templateSrv.replace('this.${test}.filters', {}, (value: string) => { passedValue = value; + return ''; }); expect(passedValue).toBe('[object Object]'); @@ -763,6 +665,7 @@ describe('templateSrv', () => { let passedValue: string | null = null; _templateSrv.replace('this.${test}.filters', {}, (value: string) => { passedValue = value; + return ''; }); expect(passedValue).toBe('hello'); @@ -904,15 +807,6 @@ describe('templateSrv', () => { const target = _templateSrv.replace('${adhoc}', { adhoc: { value: 'value2', text: 'value2' } }, 'queryparam'); expect(target).toBe('var-adhoc=value2'); }); - - it('Variable named ${__all_variables} is already formatted so skip any formatting', () => { - const target = _templateSrv.replace( - '${__all_variables}', - { __all_variables: { value: 'var-server=server+name+with+plus%2B', skipFormat: true } }, - 'percentencode' - ); - expect(target).toBe('var-server=server+name+with+plus%2B'); - }); }); describe('scenes compatibility', () => { diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index a2b9cfb0923..bcca0087752 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -7,6 +7,7 @@ import { AdHocVariableFilter, AdHocVariableModel, TypedVariableModel, + ScopedVar, } from '@grafana/data'; import { getDataSourceSrv, @@ -14,7 +15,7 @@ import { TemplateSrv as BaseTemplateSrv, VariableInterpolation, } from '@grafana/runtime'; -import { sceneGraph, FormatRegistryID, formatRegistry, VariableCustomFormatterFn } from '@grafana/scenes'; +import { sceneGraph, FormatRegistryID, VariableCustomFormatterFn } from '@grafana/scenes'; import { variableAdapters } from '../variables/adapters'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants'; @@ -22,7 +23,8 @@ import { isAdHoc } from '../variables/guard'; import { getFilteredVariables, getVariables, getVariableWithName } from '../variables/state/selectors'; import { variableRegex } from '../variables/utils'; -import { getVariableWrapper } from './LegacyVariableWrapper'; +import { formatVariableValue } from './formatVariableValue'; +import { macroRegistry } from './macroRegistry'; interface FieldAccessorCache { [key: string]: (obj: any) => any; @@ -135,51 +137,6 @@ export class TemplateSrv implements BaseTemplateSrv { return filters; } - formatValue(value: any, format?: any, variable?: any, text?: string): string { - // for some scopedVars there is no variable - variable = variable || {}; - - if (value === null || value === undefined) { - return ''; - } - - if (isAdHoc(variable) && format !== FormatRegistryID.queryParam) { - return ''; - } - - // if it's an object transform value to string - if (!Array.isArray(value) && typeof value === 'object') { - value = `${value}`; - } - - if (typeof format === 'function') { - return format(value, variable, this.formatValue); - } - - if (!format) { - format = FormatRegistryID.glob; - } - - // some formats have arguments that come after ':' character - let args = format.split(':'); - if (args.length > 1) { - format = args[0]; - args = args.slice(1); - } else { - args = []; - } - - let formatItem = formatRegistry.getIfExists(format); - - if (!formatItem) { - console.error(`Variable format ${format} not found. Using glob format as fallback.`); - formatItem = formatRegistry.get(FormatRegistryID.glob); - } - - const formatVariable = getVariableWrapper(variable, value, text ?? value); - return formatItem.formatter(value, args, formatVariable); - } - setGrafanaVariable(name: string, value: any) { this.grafanaVariables.set(name, value); } @@ -257,12 +214,7 @@ export class TemplateSrv implements BaseTemplateSrv { return (this.fieldAccessorCache[fieldPath] = property(fieldPath)); } - private getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars) { - const scopedVar = scopedVars[variableName]; - if (!scopedVar) { - return null; - } - + private getVariableValue(scopedVar: ScopedVar, fieldPath: string | undefined) { if (fieldPath) { return this.getFieldAccessor(fieldPath)(scopedVar.value); } @@ -270,13 +222,7 @@ export class TemplateSrv implements BaseTemplateSrv { return scopedVar.value; } - private getVariableText(variableName: string, value: any, scopedVars: ScopedVars) { - const scopedVar = scopedVars[variableName]; - - if (!scopedVar) { - return null; - } - + private getVariableText(scopedVar: ScopedVar, value: any) { if (scopedVar.value === value || typeof value !== 'string') { return scopedVar.text; } @@ -287,7 +233,7 @@ export class TemplateSrv implements BaseTemplateSrv { replace( target?: string, scopedVars?: ScopedVars, - format?: string | Function, + format?: string | Function | undefined, interpolations?: VariableInterpolation[] ): string { if (scopedVars && scopedVars.__sceneObject) { @@ -321,25 +267,26 @@ export class TemplateSrv implements BaseTemplateSrv { match: string, variableName: string, fieldPath: string, - format: string | Function | undefined, + format: string | VariableCustomFormatterFn | undefined, scopedVars: ScopedVars | undefined ) { const variable = this.getVariableAtIndex(variableName); + const scopedVar = scopedVars?.[variableName]; - if (scopedVars) { - const value = this.getVariableValue(variableName, fieldPath, scopedVars); - const text = this.getVariableText(variableName, value, scopedVars); + if (scopedVar) { + const value = this.getVariableValue(scopedVar, fieldPath); + const text = this.getVariableText(scopedVar, value); if (value !== null && value !== undefined) { - if (scopedVars[variableName]?.skipFormat) { - format = undefined; - } - - return this.formatValue(value, format, variable, text); + return formatVariableValue(value, format, variable, text); } } if (!variable) { + if (macroRegistry[variableName]) { + return macroRegistry[variableName](match, fieldPath, scopedVars, format); + } + return match; } @@ -347,12 +294,12 @@ export class TemplateSrv implements BaseTemplateSrv { const value = variableAdapters.get(variable.type).getValueForUrl(variable); const text = isAdHoc(variable) ? variable.id : variable.current.text; - return this.formatValue(value, format, variable, text); + return formatVariableValue(value, format, variable, text); } const systemValue = this.grafanaVariables.get(variable.current.value); if (systemValue) { - return this.formatValue(systemValue, format, variable); + return formatVariableValue(systemValue, format, variable); } let value = variable.current.value; @@ -368,15 +315,13 @@ export class TemplateSrv implements BaseTemplateSrv { } if (fieldPath) { - const fieldValue = this.getVariableValue(variableName, fieldPath, { - [variableName]: { value, text }, - }); + const fieldValue = this.getVariableValue({ value, text }, fieldPath); if (fieldValue !== null && fieldValue !== undefined) { - return this.formatValue(fieldValue, format, variable, text); + return formatVariableValue(fieldValue, format, variable, text); } } - return this.formatValue(value, format, variable, text); + return formatVariableValue(value, format, variable, text); } /** diff --git a/public/app/features/templating/types.ts b/public/app/features/templating/types.ts new file mode 100644 index 00000000000..feae3c46caa --- /dev/null +++ b/public/app/features/templating/types.ts @@ -0,0 +1,11 @@ +import { ScopedVars } from '@grafana/data'; +import { VariableCustomFormatterFn } from '@grafana/scenes'; + +export interface MacroHandler { + ( + match: string, + fieldPath: string | undefined, + scopedVars: ScopedVars | undefined, + format: string | VariableCustomFormatterFn | undefined + ): string; +} diff --git a/public/app/features/variables/getAllVariableValuesForUrl.test.ts b/public/app/features/variables/getAllVariableValuesForUrl.test.ts index 8ffbef97da1..2ce19745d8a 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.test.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.test.ts @@ -101,13 +101,15 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + initTemplateSrv(key, [ + { type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] }, skipUrlSync: true }, + ]) ); }); it('should not set scoped value as url params', () => { const params = getVariablesUrlParams({ - test: { value: 'val1', text: 'val1text', skipUrlSync: true }, + test: { value: 'val1', text: 'val1text' }, }); expect(params['var-test']).toBe(undefined); }); diff --git a/public/app/features/variables/getAllVariableValuesForUrl.ts b/public/app/features/variables/getAllVariableValuesForUrl.ts index 915541024c0..276f5139c33 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.ts @@ -10,16 +10,15 @@ export function getVariablesUrlParams(scopedVars?: ScopedVars): UrlQueryMap { for (let i = 0; i < variables.length; i++) { const variable = variables[i]; - if (scopedVars && scopedVars[variable.name] !== void 0) { - if (scopedVars[variable.name].skipUrlSync) { - continue; - } - params[VARIABLE_PREFIX + variable.name] = scopedVars[variable.name].value; + const scopedVar = scopedVars && scopedVars[variable.name]; + + if (variable.skipUrlSync) { + continue; + } + + if (scopedVar) { + params[VARIABLE_PREFIX + variable.name] = scopedVar.value; } else { - // @ts-ignore - if (variable.skipUrlSync) { - continue; - } params[VARIABLE_PREFIX + variable.name] = variableAdapters.get(variable.type).getValueForUrl(variable as any); } } diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index 094720d4736..4fa1f70ba87 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -193,10 +193,7 @@ export class TestDataDataSource extends DataSourceWithBackend { variablesQuery(target: TestData, options: DataQueryRequest): Observable { const query = target.stringInput ?? ''; - const interpolatedQuery = this.templateSrv.replace( - query, - getSearchFilterScopedVar({ query, wildcardChar: '*', options: options.scopedVars }) - ); + const interpolatedQuery = this.templateSrv.replace(query, getSearchFilterScopedVar({ query, wildcardChar: '*' })); const children = queryMetricTree(interpolatedQuery); const items = children.map((item) => ({ value: item.name, text: item.name })); const dataFrame = new ArrayDataFrame(items); diff --git a/public/app/plugins/panel/table-old/specs/renderer.test.ts b/public/app/plugins/panel/table-old/specs/renderer.test.ts index e7e18baf4f5..cadc0a8da48 100644 --- a/public/app/plugins/panel/table-old/specs/renderer.test.ts +++ b/public/app/plugins/panel/table-old/specs/renderer.test.ts @@ -18,7 +18,7 @@ const templateSrv = { if (scopedVars) { // For testing variables replacement in link each(scopedVars, (val, key) => { - value = value.replace('$' + key, val.value); + value = value.replace('$' + key, val?.value); }); } return value;