From 8c585a4ebf570dba33dde779dddd345c1b83fd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 16 Nov 2022 11:36:30 +0100 Subject: [PATCH] Scene: Variables interpolation formats and multi value handling (#58591) * Component that can cache and extract variable dependencies * Component that can cache and extract variable dependencies * Updates * Refactoring * Lots of refactoring and iterations of supporting both re-rendering and query re-execution * Updated SceneCanvasText * Updated name of file * Updated * Refactoring a bit * Added back getName * Added comment * minor fix * Minor fix * Merge fixes * Scene variable interpolation progress * Merge fixes * Added all format registeries * Progress on multi value support * Progress on multi value support * Updates * Progress on scoped vars * Fixed circular dependency * Updates * Some review fixes * Updated comment * Added forceRender function * Add back fail on console log * Update public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts * Moving functions from SceneObjectBase * fixing tests * Fixed e2e Co-authored-by: Dominik Prokop --- .betterer.results | 6 +- .../load-options-from-url.spec.ts | 16 +- .../set-options-from-ui.spec.ts | 21 +- public/app/core/utils/kbn.ts | 2 +- .../scenes/components/SceneCanvasText.tsx | 3 +- .../scenes/components/ScenePanelRepeater.tsx | 3 +- .../scenes/components/SceneTimePicker.tsx | 3 +- .../features/scenes/components/VizPanel.tsx | 10 +- .../components/layout/SceneGridLayout.tsx | 3 +- .../scenes/core/SceneComponentWrapper.tsx | 26 +- .../features/scenes/core/SceneObjectBase.tsx | 101 +----- public/app/features/scenes/core/sceneGraph.ts | 120 +++++++ public/app/features/scenes/core/types.ts | 22 +- .../editor/SceneComponentEditWrapper.tsx | 9 +- .../scenes/editor/SceneEditManager.tsx | 5 + .../scenes/editor/SceneObjectTree.tsx | 3 +- .../scenes/querying/SceneQueryRunner.ts | 5 +- .../features/scenes/scenes/variablesDemo.tsx | 3 +- .../components/VariableValueSelect.tsx | 12 +- .../components/VariableValueSelectors.tsx | 3 +- .../interpolation/ScopedVarsVariable.ts | 72 ++++ .../interpolation/formatRegistry.test.ts | 68 ++++ .../variables/interpolation/formatRegistry.ts | 326 ++++++++++++++++++ .../interpolation/sceneInterpolator.test.ts | 149 ++++++++ .../interpolation/sceneInterpolator.ts | 128 +++++++ .../sceneTemplateInterpolator.test.ts | 63 ---- .../variables/sceneTemplateInterpolator.ts | 53 --- .../variables/sets/SceneVariableSet.test.tsx | 4 +- public/app/features/scenes/variables/types.ts | 9 +- .../variants/MultiValueVariable.test.ts | 107 +++++- .../variables/variants/MultiValueVariable.ts | 96 ++++-- .../variables/variants/TestVariable.tsx | 15 +- .../datasource/testdata/metricTree.test.ts | 4 +- .../plugins/datasource/testdata/metricTree.ts | 2 +- 34 files changed, 1157 insertions(+), 315 deletions(-) create mode 100644 public/app/features/scenes/core/sceneGraph.ts create mode 100644 public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts create mode 100644 public/app/features/scenes/variables/interpolation/formatRegistry.test.ts create mode 100644 public/app/features/scenes/variables/interpolation/formatRegistry.ts create mode 100644 public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts create mode 100644 public/app/features/scenes/variables/interpolation/sceneInterpolator.ts delete mode 100644 public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts delete mode 100644 public/app/features/scenes/variables/sceneTemplateInterpolator.ts diff --git a/.betterer.results b/.betterer.results index fe3efee0f46..bfb01521da9 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4569,13 +4569,15 @@ exports[`better eslint`] = { "public/app/features/scenes/core/SceneObjectBase.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/features/scenes/core/sceneGraph.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/scenes/core/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/e2e/dashboards-suite/load-options-from-url.spec.ts b/e2e/dashboards-suite/load-options-from-url.spec.ts index bb7f1d7bf20..4c03e37675b 100644 --- a/e2e/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e/dashboards-suite/load-options-from-url.spec.ts @@ -20,7 +20,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -33,7 +33,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -46,7 +46,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -72,7 +72,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -85,7 +85,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -98,7 +98,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -135,7 +135,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -147,7 +147,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 10); + e2e().get('.variable-option').should('have.length', 65); }); }); }); diff --git a/e2e/dashboards-suite/set-options-from-ui.spec.ts b/e2e/dashboards-suite/set-options-from-ui.spec.ts index 9449ce8fb7d..cacbb1dd007 100644 --- a/e2e/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e/dashboards-suite/set-options-from-ui.spec.ts @@ -27,7 +27,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -42,19 +42,16 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 10); + e2e().get('.variable-option').should('have.length', 65); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAA').should('be.visible'); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAB').should('be.visible'); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAC').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCA').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCB').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAD').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAE').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAF').should('be.visible'); }); it('adding a value that is not part of dependents options should add the new values dependant options', () => { @@ -81,7 +78,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 7); + e2e().get('.variable-option').should('have.length', 17); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -97,7 +94,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -132,7 +129,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -145,7 +142,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 1921244088a..e09f153c960 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -24,7 +24,7 @@ const kbn = { s: 1, ms: 0.001, } as { [index: string]: number }, - regexEscape: (value: string) => value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'), + regexEscape: (value: string): string => value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'), /** @deprecated since 7.2, use grafana/data */ roundInterval: (interval: number) => { diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx index b76e1e748d4..6552a45b28a 100644 --- a/public/app/features/scenes/components/SceneCanvasText.tsx +++ b/public/app/features/scenes/components/SceneCanvasText.tsx @@ -3,6 +3,7 @@ import React, { CSSProperties } from 'react'; import { Field, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; @@ -31,7 +32,7 @@ export class SceneCanvasText extends SceneObjectBase { return (
- {model.interpolate(text)} + {sceneGraph.interpolate(model, text)}
); }; diff --git a/public/app/features/scenes/components/ScenePanelRepeater.tsx b/public/app/features/scenes/components/ScenePanelRepeater.tsx index 26063a64211..42a18fd8832 100644 --- a/public/app/features/scenes/components/ScenePanelRepeater.tsx +++ b/public/app/features/scenes/components/ScenePanelRepeater.tsx @@ -4,6 +4,7 @@ import { LoadingState, PanelData } from '@grafana/data'; import { SceneDataNode } from '../core/SceneDataNode'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneObject, @@ -21,7 +22,7 @@ export class ScenePanelRepeater extends SceneObjectBase { super.activate(); this._subs.add( - this.getData().subscribeToState({ + sceneGraph.getData(this).subscribeToState({ next: (data) => { if (data.data?.state === LoadingState.Done) { this.performRepeat(data.data); diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx index ad6b8281d9d..d850f0e769a 100644 --- a/public/app/features/scenes/components/SceneTimePicker.tsx +++ b/public/app/features/scenes/components/SceneTimePicker.tsx @@ -4,6 +4,7 @@ import { RefreshPicker, ToolbarButtonRow } from '@grafana/ui'; import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneObjectStatePlain } from '../core/types'; export interface SceneTimePickerState extends SceneObjectStatePlain { @@ -16,7 +17,7 @@ export class SceneTimePicker extends SceneObjectBase { function SceneTimePickerRenderer({ model }: SceneComponentProps) { const { hidePicker } = model.useState(); - const timeRange = model.getTimeRange(); + const timeRange = sceneGraph.getTimeRange(model); const timeRangeState = timeRange.useState(); if (hidePicker) { diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx index 3882a898552..2ffb21bfd90 100644 --- a/public/app/features/scenes/components/VizPanel.tsx +++ b/public/app/features/scenes/components/VizPanel.tsx @@ -6,6 +6,7 @@ import { PanelRenderer } from '@grafana/runtime'; import { Field, PanelChrome, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; @@ -27,7 +28,7 @@ export class VizPanel extends SceneObjectBase { }); public onSetTimeRange = (timeRange: AbsoluteTimeRange) => { - const sceneTimeRange = this.getTimeRange(); + const sceneTimeRange = sceneGraph.getTimeRange(this); sceneTimeRange.setState({ raw: { from: toUtc(timeRange.from), @@ -41,12 +42,13 @@ export class VizPanel extends SceneObjectBase { function ScenePanelRenderer({ model }: SceneComponentProps) { const { title, pluginId, options, fieldConfig, ...state } = model.useState(); - const { data } = model.getData().useState(); - const layout = model.getLayout(); + const { data } = sceneGraph.getData(model).useState(); + + const layout = sceneGraph.getLayout(model); const isDraggable = layout.state.isDraggable ? state.isDraggable : false; const dragHandle = ; - const titleInterpolated = model.interpolate(title); + const titleInterpolated = sceneGraph.interpolate(model, title); return ( diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.tsx index 645a110bcec..6e70b2afd31 100644 --- a/public/app/features/scenes/components/layout/SceneGridLayout.tsx +++ b/public/app/features/scenes/components/layout/SceneGridLayout.tsx @@ -8,6 +8,7 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { DEFAULT_PANEL_SPAN, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { sceneGraph } from '../../core/sceneGraph'; import { SceneComponentProps, SceneLayoutChild, @@ -411,7 +412,7 @@ export class SceneGridRow extends SceneObjectBase { function SceneGridRowRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getSceneGridRowStyles); const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); - const layout = model.getLayout(); + const layout = sceneGraph.getLayout(model); const dragHandle = ; return ( diff --git a/public/app/features/scenes/core/SceneComponentWrapper.tsx b/public/app/features/scenes/core/SceneComponentWrapper.tsx index 6064c8f2dba..caf04343783 100644 --- a/public/app/features/scenes/core/SceneComponentWrapper.tsx +++ b/public/app/features/scenes/core/SceneComponentWrapper.tsx @@ -1,8 +1,6 @@ import React, { useEffect } from 'react'; -import { SceneComponentEditingWrapper } from '../editor/SceneComponentEditWrapper'; - -import { SceneComponentProps, SceneObject } from './types'; +import { SceneComponentProps, SceneEditor, SceneObject } from './types'; export function SceneComponentWrapper({ model, @@ -32,9 +30,29 @@ export function SceneComponentWrapper({ return inner; } - return {inner}; + const editor = getSceneEditor(model); + const EditWrapper = getSceneEditor(model).getEditComponentWrapper(); + + return ( + + {inner} + + ); } function EmptyRenderer(_: SceneComponentProps): React.ReactElement | null { return null; } + +function getSceneEditor(sceneObject: SceneObject): SceneEditor { + const { $editor } = sceneObject.state; + if ($editor) { + return $editor; + } + + if (sceneObject.parent) { + return getSceneEditor(sceneObject.parent); + } + + throw new Error('No editor found in scene tree'); +} diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index 9ba3bfa1b0d..f0c5f75b27f 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -5,20 +5,11 @@ import { v4 as uuidv4 } from 'uuid'; import { BusEvent, BusEventHandler, BusEventType, EventBusSrv } from '@grafana/data'; import { useForceUpdate } from '@grafana/ui'; -import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator'; -import { SceneVariables, SceneVariableDependencyConfigLike } from '../variables/types'; +import { SceneVariableDependencyConfigLike } from '../variables/types'; import { SceneComponentWrapper } from './SceneComponentWrapper'; import { SceneObjectStateChangedEvent } from './events'; -import { - SceneDataState, - SceneObject, - SceneComponent, - SceneEditor, - SceneTimeRange, - SceneObjectState, - SceneLayoutState, -} from './types'; +import { SceneObject, SceneComponent, SceneObjectState } from './types'; import { cloneSceneObject, forEachSceneObjectInState } from './utils'; export abstract class SceneObjectBase @@ -185,81 +176,6 @@ export abstract class SceneObjectBase { - const { $data } = this.state; - if ($data) { - return $data; - } - - if (this.parent) { - return this.parent.getData(); - } - - throw new Error('No data found in scene tree'); - } - - public getVariables(): SceneVariables | undefined { - if (this.state.$variables) { - return this.state.$variables; - } - - if (this.parent) { - return this.parent.getVariables(); - } - - return undefined; - } - - /** - * Will walk up the scene object graph to the closest $layout scene object - */ - public getLayout(): SceneObject { - if (this.constructor.name === 'SceneFlexLayout' || this.constructor.name === 'SceneGridLayout') { - return this as SceneObject; - } - - if (this.parent) { - return this.parent.getLayout(); - } - - throw new Error('No layout found in scene tree'); - } - - /** - * Will walk up the scene object graph to the closest $editor scene object - */ - public getSceneEditor(): SceneEditor { - const { $editor } = this.state; - if ($editor) { - return $editor; - } - - if (this.parent) { - return this.parent.getSceneEditor(); - } - - throw new Error('No editor found in scene tree'); - } - /** Force a re-render, should only be needed when variable values change */ public forceRender(): void { this.setState({}); @@ -271,19 +187,6 @@ export abstract class SceneObjectBase): this { return cloneSceneObject(this, withState); } - - /** - * Interpolates the given string using the current scene object as context. - * TODO: Cache interpolatinos? - */ - public interpolate(value: string | undefined) { - // Skip interpolation if there are no variable depdendencies - if (!value || !this._variableDependency || this._variableDependency.getNames().size === 0) { - return value; - } - - return sceneTemplateInterpolator(value, this); - } } /** diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts new file mode 100644 index 00000000000..6cd7e4606d4 --- /dev/null +++ b/public/app/features/scenes/core/sceneGraph.ts @@ -0,0 +1,120 @@ +import { getDefaultTimeRange, LoadingState } from '@grafana/data'; + +import { sceneInterpolator } from '../variables/interpolation/sceneInterpolator'; +import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; +import { SceneVariables } from '../variables/types'; + +import { SceneDataNode } from './SceneDataNode'; +import { SceneTimeRange as SceneTimeRangeImpl } from './SceneTimeRange'; +import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRange } from './types'; + +/** + * Get the closest node with variables + */ +export function getVariables(sceneObject: SceneObject): SceneVariables { + if (sceneObject.state.$variables) { + return sceneObject.state.$variables; + } + + if (sceneObject.parent) { + return getVariables(sceneObject.parent); + } + + return EmptyVariableSet; +} + +/** + * Will walk up the scene object graph to the closest $data scene object + */ +export function getData(sceneObject: SceneObject): SceneObject { + const { $data } = sceneObject.state; + if ($data) { + return $data; + } + + if (sceneObject.parent) { + return getData(sceneObject.parent); + } + + return EmptyDataNode; +} + +/** + * Will walk up the scene object graph to the closest $timeRange scene object + */ +export function getTimeRange(sceneObject: SceneObject): SceneTimeRange { + const { $timeRange } = sceneObject.state; + if ($timeRange) { + return $timeRange; + } + + if (sceneObject.parent) { + return getTimeRange(sceneObject.parent); + } + + return DefaultTimeRange; +} + +/** + * Will walk up the scene object graph to the closest $editor scene object + */ +export function getSceneEditor(sceneObject: SceneObject): SceneEditor { + const { $editor } = sceneObject.state; + if ($editor) { + return $editor; + } + + if (sceneObject.parent) { + return getSceneEditor(sceneObject.parent); + } + + throw new Error('No editor found in scene tree'); +} + +/** + * Will walk up the scene object graph to the closest $layout scene object + */ +export function getLayout(scene: SceneObject): SceneObject { + if (scene.constructor.name === 'SceneFlexLayout' || scene.constructor.name === 'SceneGridLayout') { + return scene as SceneObject; + } + + if (scene.parent) { + return getLayout(scene.parent); + } + + throw new Error('No layout found in scene tree'); +} + +/** + * Interpolates the given string using the current scene object as context. * + */ +export function interpolate(sceneObject: SceneObject, value: string | undefined | null): string { + // Skip interpolation if there are no variable dependencies + if (!value || !sceneObject.variableDependency || sceneObject.variableDependency.getNames().size === 0) { + return value ?? ''; + } + + return sceneInterpolator(sceneObject, value); +} + +export const EmptyVariableSet = new SceneVariableSet({ variables: [] }); + +export const EmptyDataNode = new SceneDataNode({ + data: { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }, +}); + +export const DefaultTimeRange = new SceneTimeRangeImpl(getDefaultTimeRange()); + +export const sceneGraph = { + getVariables, + getData, + getTimeRange, + getSceneEditor, + getLayout, + interpolate, +}; diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index f99d764c064..d818e5bba4b 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -86,24 +86,9 @@ export interface SceneObject /** Called when component unmounts. Unsubscribe and closes all subscriptions */ deactivate(): void; - /** Get the scene editor */ - getSceneEditor(): SceneEditor; - /** Get the scene root */ getRoot(): SceneObject; - /** Get the closest node with data */ - getData(): SceneObject; - - /** Get the closest node with variables */ - getVariables(): SceneVariables | undefined; - - /** Get the closest node with time range */ - getTimeRange(): SceneTimeRange; - - /** Get the closest layout node */ - getLayout(): SceneObject; - /** Returns a deep clone this object and all its children */ clone(state?: Partial): this; @@ -134,6 +119,13 @@ export interface SceneEditor extends SceneObject { onMouseEnterObject(model: SceneObject): void; onMouseLeaveObject(model: SceneObject): void; onSelectObject(model: SceneObject): void; + getEditComponentWrapper(): React.ComponentType; +} + +interface SceneComponentEditWrapperProps { + editor: SceneEditor; + model: SceneObject; + children: React.ReactNode; } export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {} diff --git a/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx b/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx index 5049e06f893..eaa6a77d274 100644 --- a/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx +++ b/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx @@ -4,17 +4,18 @@ import React, { CSSProperties } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -import { SceneObject } from '../core/types'; +import { SceneEditor, SceneObject } from '../core/types'; -export function SceneComponentEditingWrapper({ +export function SceneComponentEditWrapper({ model, + editor, children, }: { - model: T; + model: SceneObject; + editor: SceneEditor; children: React.ReactNode; }) { const styles = useStyles2(getStyles); - const editor = model.getSceneEditor(); const { hoverObject, selectedObject } = editor.useState(); const onMouseEnter = () => editor.onMouseEnterObject(model); diff --git a/public/app/features/scenes/editor/SceneEditManager.tsx b/public/app/features/scenes/editor/SceneEditManager.tsx index f7fea9e0bc9..ba40ce6975a 100644 --- a/public/app/features/scenes/editor/SceneEditManager.tsx +++ b/public/app/features/scenes/editor/SceneEditManager.tsx @@ -7,6 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneEditorState, SceneEditor, SceneObject, SceneComponentProps, SceneComponent } from '../core/types'; +import { SceneComponentEditWrapper } from './SceneComponentEditWrapper'; import { SceneObjectEditor } from './SceneObjectEditor'; import { SceneObjectTree } from './SceneObjectTree'; @@ -32,6 +33,10 @@ export class SceneEditManager extends SceneObjectBase implemen public onSelectObject(model: SceneObject) { this.setState({ selectedObject: { ref: model } }); } + + public getEditComponentWrapper() { + return SceneComponentEditWrapper; + } } function SceneEditorRenderer({ model, isEditing }: SceneComponentProps) { diff --git a/public/app/features/scenes/editor/SceneObjectTree.tsx b/public/app/features/scenes/editor/SceneObjectTree.tsx index c167300afae..bd612a6e172 100644 --- a/public/app/features/scenes/editor/SceneObjectTree.tsx +++ b/public/app/features/scenes/editor/SceneObjectTree.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, useStyles2 } from '@grafana/ui'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneObject, isSceneObject, SceneLayoutChild } from '../core/types'; export interface Props { @@ -31,7 +32,7 @@ export function SceneObjectTree({ node, selectedObject }: Props) { const name = node.constructor.name; const isSelected = selectedObject === node; - const onSelectNode = () => node.getSceneEditor().onSelectObject(node); + const onSelectNode = () => sceneGraph.getSceneEditor(node).onSelectObject(node); return (
diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index 01bf47e1fc0..ba9312f6c43 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -17,6 +17,7 @@ import { getNextRequestId } from 'app/features/query/state/PanelQueryRunner'; import { runRequest } from 'app/features/query/state/runRequest'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneObjectStatePlain } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; @@ -40,7 +41,7 @@ export class SceneQueryRunner extends SceneObjectBase { public activate() { super.activate(); - const timeRange = this.getTimeRange(); + const timeRange = sceneGraph.getTimeRange(this); this._subs.add( timeRange.subscribeToState({ @@ -65,7 +66,7 @@ export class SceneQueryRunner extends SceneObjectBase { } public runQueries() { - const timeRange = this.getTimeRange(); + const timeRange = sceneGraph.getTimeRange(this); this.runWithTimeRange(timeRange.state); } diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx index 1f2e8d93663..a005d6796c1 100644 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -31,6 +31,7 @@ export function getVariablesDemo(): Scene { query: 'A.$server.*', value: 'pod', delayMs: 1000, + isMulti: true, text: '', options: [], }), @@ -59,7 +60,7 @@ export function getVariablesDemo(): Scene { }), new SceneCanvasText({ size: { width: '40%' }, - text: 'server - pod: ${server} - ${pod}', + text: 'server: ${server} pod:${pod}', fontSize: 20, align: 'center', }), diff --git a/public/app/features/scenes/variables/components/VariableValueSelect.tsx b/public/app/features/scenes/variables/components/VariableValueSelect.tsx index b70dcc28920..c746fa9988c 100644 --- a/public/app/features/scenes/variables/components/VariableValueSelect.tsx +++ b/public/app/features/scenes/variables/components/VariableValueSelect.tsx @@ -19,7 +19,13 @@ export function VariableValueSelect({ model }: SceneComponentProps { + model.changeValueTo( + newValue.map((v) => v.value!), + newValue.map((v) => v.label!) + ); + }} /> ); } @@ -33,7 +39,9 @@ export function VariableValueSelect({ model }: SceneComponentProps { + model.changeValueTo(newValue.value!, newValue.label!); + }} /> ); } diff --git a/public/app/features/scenes/variables/components/VariableValueSelectors.tsx b/public/app/features/scenes/variables/components/VariableValueSelectors.tsx index 2758d612ea5..6029e20096c 100644 --- a/public/app/features/scenes/variables/components/VariableValueSelectors.tsx +++ b/public/app/features/scenes/variables/components/VariableValueSelectors.tsx @@ -5,6 +5,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Tooltip } from '@grafana/ui'; import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { sceneGraph } from '../../core/sceneGraph'; import { SceneComponentProps, SceneObject, SceneObjectStatePlain } from '../../core/types'; import { SceneVariableState } from '../types'; @@ -13,7 +14,7 @@ export class VariableValueSelectors extends SceneObjectBase) { - const variables = model.getVariables()!.useState(); + const variables = sceneGraph.getVariables(model)!.useState(); return ( <> diff --git a/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts new file mode 100644 index 00000000000..ff555e5a9bf --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts @@ -0,0 +1,72 @@ +import { property } from 'lodash'; + +import { ScopedVar } from '@grafana/data'; + +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { SceneVariable, SceneVariableState, VariableValue } from '../types'; + +export interface ScopedVarsProxyVariableState extends SceneVariableState { + value: ScopedVar; +} + +export class ScopedVarsVariable + extends SceneObjectBase + implements SceneVariable +{ + private static fieldAccessorCache: FieldAccessorCache = {}; + + public getValue(fieldPath: string): VariableValue { + let { value } = this.state; + let realValue = value.value; + + if (fieldPath) { + realValue = this.getFieldAccessor(fieldPath)(value.value); + } else { + realValue = value.value; + } + + if (realValue === 'string' || realValue === 'number' || realValue === 'boolean') { + return realValue; + } + + return String(realValue); + } + + public getValueText(): string { + const { value } = this.state; + + if (value.text != null) { + return String(value.text); + } + + return String(value); + } + + private getFieldAccessor(fieldPath: string) { + const accessor = ScopedVarsVariable.fieldAccessorCache[fieldPath]; + if (accessor) { + return accessor; + } + + return (ScopedVarsVariable.fieldAccessorCache[fieldPath] = property(fieldPath)); + } +} + +interface FieldAccessorCache { + [key: string]: (obj: unknown) => unknown; +} + +let scopedVarsVariable: ScopedVarsVariable | undefined; + +/** + * Reuses a single instance to avoid unnecessary memory allocations + */ +export function getSceneVariableForScopedVar(name: string, value: ScopedVar) { + if (!scopedVarsVariable) { + scopedVarsVariable = new ScopedVarsVariable({ name, value }); + } else { + scopedVarsVariable.setState({ name, value }); + } + + return scopedVarsVariable; +} diff --git a/public/app/features/scenes/variables/interpolation/formatRegistry.test.ts b/public/app/features/scenes/variables/interpolation/formatRegistry.test.ts new file mode 100644 index 00000000000..6847489c135 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/formatRegistry.test.ts @@ -0,0 +1,68 @@ +import { VariableValue } from '../types'; +import { TestVariable } from '../variants/TestVariable'; + +import { formatRegistry, FormatRegistryID } from './formatRegistry'; + +function formatValue( + formatId: FormatRegistryID, + value: T, + text?: string, + args: string[] = [] +): string { + const variable = new TestVariable({ name: 'server', value, text }); + return formatRegistry.get(formatId).formatter(value, args, variable); +} + +describe('formatRegistry', () => { + it('Can format values acccording to format', () => { + expect(formatValue(FormatRegistryID.lucene, 'foo bar')).toBe('foo\\ bar'); + expect(formatValue(FormatRegistryID.lucene, '-1')).toBe('-1'); + expect(formatValue(FormatRegistryID.lucene, '-test')).toBe('\\-test'); + expect(formatValue(FormatRegistryID.lucene, ['foo bar', 'baz'])).toBe('("foo\\ bar" OR "baz")'); + expect(formatValue(FormatRegistryID.lucene, [])).toBe('__empty__'); + + expect(formatValue(FormatRegistryID.glob, 'foo')).toBe('foo'); + expect(formatValue(FormatRegistryID.glob, ['AA', 'BB', 'C.*'])).toBe('{AA,BB,C.*}'); + + expect(formatValue(FormatRegistryID.text, 'v', 'display text')).toBe('display text'); + + expect(formatValue(FormatRegistryID.raw, [12, 13])).toBe('12,13'); + expect(formatValue(FormatRegistryID.raw, '#Ƴ ̇¹"Ä1"#!"#!½')).toBe('#Ƴ ̇¹"Ä1"#!"#!½'); + + expect(formatValue(FormatRegistryID.regex, 'test.')).toBe('test\\.'); + expect(formatValue(FormatRegistryID.regex, ['test.'])).toBe('test\\.'); + expect(formatValue(FormatRegistryID.regex, ['test.', 'test2'])).toBe('(test\\.|test2)'); + + expect(formatValue(FormatRegistryID.pipe, ['test', 'test2'])).toBe('test|test2'); + + expect(formatValue(FormatRegistryID.distributed, ['test'])).toBe('test'); + expect(formatValue(FormatRegistryID.distributed, ['test', 'test2'])).toBe('test,server=test2'); + + expect(formatValue(FormatRegistryID.csv, 'test')).toBe('test'); + expect(formatValue(FormatRegistryID.csv, ['test', 'test2'])).toBe('test,test2'); + + expect(formatValue(FormatRegistryID.html, '')).toBe( + '<script>alert(asd)</script>' + ); + + expect(formatValue(FormatRegistryID.json, ['test', 12])).toBe('["test",12]'); + + expect(formatValue(FormatRegistryID.percentEncode, ['foo()bar BAZ', 'test2'])).toBe( + '%7Bfoo%28%29bar%20BAZ%2Ctest2%7D' + ); + + expect(formatValue(FormatRegistryID.singleQuote, 'test')).toBe(`'test'`); + expect(formatValue(FormatRegistryID.singleQuote, ['test', `test'2`])).toBe("'test','test\\'2'"); + + expect(formatValue(FormatRegistryID.doubleQuote, 'test')).toBe(`"test"`); + expect(formatValue(FormatRegistryID.doubleQuote, ['test', `test"2`])).toBe('"test","test\\"2"'); + + expect(formatValue(FormatRegistryID.sqlString, "test'value")).toBe(`'test''value'`); + expect(formatValue(FormatRegistryID.sqlString, ['test', "test'value2"])).toBe(`'test','test''value2'`); + + expect(formatValue(FormatRegistryID.date, 1594671549254)).toBe('2020-07-13T20:19:09.254Z'); + expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['seconds'])).toBe('1594671549'); + expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['iso'])).toBe('2020-07-13T20:19:09.254Z'); + expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['YYYY-MM'])).toBe('2020-07'); + }); +}); diff --git a/public/app/features/scenes/variables/interpolation/formatRegistry.ts b/public/app/features/scenes/variables/interpolation/formatRegistry.ts new file mode 100644 index 00000000000..e216ee58418 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/formatRegistry.ts @@ -0,0 +1,326 @@ +import { isArray, map, replace } from 'lodash'; + +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'; + +export interface FormatRegistryItem extends RegistryItem { + formatter(value: VariableValue, args: string[], variable: SceneVariable): 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 (Array.isArray(value)) { + if (value.length === 0) { + return '__empty__'; + } + const quotedValues = map(value, (val: string) => { + return '"' + luceneEscape(val) + '"'; + }); + return '(' + quotedValues.join(' OR ') + ')'; + } else { + return luceneEscape(`${value}`); + } + }, + }, + { + id: FormatRegistryID.raw, + name: 'raw', + description: 'Keep value as is', + formatter: (value) => String(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); + } + + if (Array.isArray(value)) { + const escapedValues = value.map((item) => { + if (typeof item === 'string') { + return kbn.regexEscape(item); + } else { + return kbn.regexEscape(String(item)); + } + }); + + if (escapedValues.length === 1) { + return escapedValues[0]; + } + + return '(' + escapedValues.join('|') + ')'; + } + + return kbn.regexEscape(`${value}`); + }, + }, + { + id: FormatRegistryID.pipe, + name: 'Pipe', + description: 'Values are separated by | character', + formatter: (value) => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value.join('|'); + } + + return `${value}`; + }, + }, + { + id: FormatRegistryID.distributed, + name: 'Distributed', + description: 'Multiple values are formatted like variable=value', + formatter: (value, args, variable) => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + value = map(value, (val: string, index: number) => { + if (index !== 0) { + return variable.state.name + '=' + val; + } else { + return val; + } + }); + + return value.join(','); + } + + return `${value}`; + }, + }, + { + id: FormatRegistryID.csv, + name: 'Csv', + description: 'Comma-separated values', + formatter: (value) => { + if (typeof value === 'string') { + return value; + } + + if (isArray(value)) { + return value.join(','); + } + + return String(value); + }, + }, + { + id: FormatRegistryID.html, + name: 'HTML', + description: 'HTML escaping of values', + formatter: (value) => { + if (typeof value === 'string') { + return textUtil.escapeHtml(value); + } + + if (isArray(value)) { + return textUtil.escapeHtml(value.join(', ')); + } + + return textUtil.escapeHtml(String(value)); + }, + }, + { + id: FormatRegistryID.json, + name: 'JSON', + description: 'JSON stringify value', + 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(','); + } + + let strVal = typeof value === 'string' ? value : String(value); + return `'${replace(strVal, 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(','); + } + + let strVal = typeof value === 'string' ? value : String(value); + return `"${replace(strVal, 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: string) => `'${replace(v, regExp, "''")}'`).join(','); + } + + let strVal = typeof value === 'string' ? value : String(value); + return `'${replace(strVal, regExp, "''")}'`; + }, + }, + { + id: FormatRegistryID.date, + name: 'Date', + description: 'Format date in different ways', + formatter: (value, args) => { + let nrValue = 0; + + if (typeof value === 'number') { + nrValue = value; + } else if (typeof value === 'string') { + nrValue = parseInt(value, 10); + } else { + return ''; + } + + const arg = args[0] ?? 'iso'; + switch (arg) { + case 'ms': + return String(value); + case 'seconds': + return `${Math.round(nrValue! / 1000)}`; + case 'iso': + return dateTime(nrValue).toISOString(); + default: + return dateTime(nrValue).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 String(value); + }, + }, + { + id: FormatRegistryID.text, + 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(); + } + + return String(value); + }, + }, + { + 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: (value, _args, variable) => { + if (Array.isArray(value)) { + return value.map((v) => formatQueryParameter(variable.state.name, v)).join('&'); + } + return formatQueryParameter(variable.state.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: VariableValueSingle) { + return encodeURIComponent(str).replace(/[!'()*]/g, (c) => { + return '%' + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +function formatQueryParameter(name: string, value: VariableValueSingle): string { + return `var-${name}=${encodeURIComponentStrict(value)}`; +} + +export function isAllValue(value: VariableValueSingle) { + return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); +} diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts new file mode 100644 index 00000000000..ac99a9e38c5 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts @@ -0,0 +1,149 @@ +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { SceneObjectStatePlain } from '../../core/types'; +import { SceneVariableSet } from '../sets/SceneVariableSet'; +import { ConstantVariable } from '../variants/ConstantVariable'; +import { ObjectVariable } from '../variants/ObjectVariable'; +import { TestVariable } from '../variants/TestVariable'; + +import { sceneInterpolator } from './sceneInterpolator'; + +interface TestSceneState extends SceneObjectStatePlain { + nested?: TestScene; +} + +class TestScene extends SceneObjectBase {} + +describe('sceneInterpolator', () => { + it('Should be interpolated and use closest variable', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: 'test', + value: 'hello', + }), + new ConstantVariable({ + name: 'atRootOnly', + value: 'RootValue', + }), + ], + }), + nested: new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: 'test', + value: 'nestedValue', + }), + ], + }), + }), + }); + + expect(sceneInterpolator(scene, '${test}')).toBe('hello'); + expect(sceneInterpolator(scene.state.nested!, '${test}')).toBe('nestedValue'); + expect(sceneInterpolator(scene.state.nested!, '${atRootOnly}')).toBe('RootValue'); + }); + + describe('Given an expression with fieldPath', () => { + it('Should interpolate correctly', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ObjectVariable({ + name: 'test', + value: { prop1: 'prop1Value' }, + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test.prop1}')).toBe('prop1Value'); + }); + }); + + it('Can use format', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: 'test', + value: 'hello', + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test:queryparam}')).toBe('var-test=hello'); + }); + + it('Can format multi valued values', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'test', + value: ['hello', 'world'], + }), + ], + }), + }); + + expect(sceneInterpolator(scene, 'test.${test}.asd')).toBe('test.{hello,world}.asd'); + }); + + it('Can format multi valued values using text formatter', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'test', + value: ['1', '2'], + text: ['hello', 'world'], + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test:text}')).toBe('hello + world'); + }); + + it('Can use formats with arguments', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'test', + value: 1594671549254, + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test:date:YYYY-MM}')).toBe('2020-07'); + }); + + it('Can use scopedVars', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [], + }), + }); + + const scopedVars = { __from: { value: 'a', text: 'b' } }; + + expect(sceneInterpolator(scene, '${__from}', scopedVars)).toBe('a'); + expect(sceneInterpolator(scene, '${__from:text}', scopedVars)).toBe('b'); + }); + + it('Can use scopedVars with fieldPath', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [], + }), + }); + + const scopedVars = { __data: { value: { name: 'Main org' }, text: '' } }; + expect(sceneInterpolator(scene, '${__data.name}', scopedVars)).toBe('Main org'); + }); +}); diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts new file mode 100644 index 00000000000..57345a856a5 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts @@ -0,0 +1,128 @@ +import { ScopedVars } from '@grafana/data'; +import { VariableModel } from '@grafana/schema'; +import { variableRegex } from 'app/features/variables/utils'; + +import { EmptyVariableSet, sceneGraph } from '../../core/sceneGraph'; +import { SceneObject } from '../../core/types'; +import { SceneVariable, VariableValue } from '../types'; + +import { getSceneVariableForScopedVar } from './ScopedVarsVariable'; +import { formatRegistry, FormatRegistryID } from './formatRegistry'; + +type CustomFormatterFn = ( + value: unknown, + legacyVariableModel: VariableModel, + legacyDefaultFormatter: CustomFormatterFn +) => string; + +/** + * This function will try to parse and replace any variable expression found in the target string. The sceneObject will be used as the source of variables. It will + * use the scene graph and walk up the parent tree until it finds the closest variable. + * + * ScopedVars should not really be needed much in the new scene architecture as they can be added to the local scene node instead of passed in interpolate function. + * It is supported here for backward compatibility and some edge cases where adding scoped vars to local scene node is not practical. + */ +export function sceneInterpolator( + sceneObject: SceneObject, + target: string | undefined | null, + scopedVars?: ScopedVars, + format?: string | CustomFormatterFn +): string { + if (!target) { + return target ?? ''; + } + + // Skip any interpolation if there are no variables in the scene object graph + if (sceneGraph.getVariables(sceneObject) === EmptyVariableSet) { + return target; + } + + variableRegex.lastIndex = 0; + + 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; + + if (scopedVars && scopedVars[variableName]) { + variable = getSceneVariableForScopedVar(variableName, scopedVars[variableName]); + } else { + variable = lookupSceneVariable(variableName, sceneObject); + } + + if (!variable) { + return match; + } + + return formatValue(variable, variable.getValue(fieldPath), fmt); + }); +} + +function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { + const variables = sceneObject.state.$variables; + if (!variables) { + if (sceneObject.parent) { + return lookupSceneVariable(name, sceneObject.parent); + } else { + return null; + } + } + + const found = variables.getByName(name); + if (found) { + return found; + } else if (sceneObject.parent) { + return lookupSceneVariable(name, sceneObject.parent); + } + + return null; +} + +function formatValue( + variable: SceneVariable, + value: VariableValue | undefined | null, + formatNameOrFn: string | CustomFormatterFn +): string { + 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 formatNameOrFn === 'function') { + // legacy custom formatter function, TODO + //return format(value, {}, this.formatValue); + throw new Error('Custom formatter function not supported'); + } + + let args: string[] = []; + + if (!formatNameOrFn) { + formatNameOrFn = FormatRegistryID.glob; + } else { + // some formats have arguments that come after ':' character + args = formatNameOrFn.split(':'); + if (args.length > 1) { + formatNameOrFn = args[0]; + args = args.slice(1); + } else { + args = []; + } + } + + let formatter = formatRegistry.getIfExists(formatNameOrFn); + + if (!formatter) { + console.error(`Variable format ${formatNameOrFn} not found. Using glob format as fallback.`); + formatter = formatRegistry.get(FormatRegistryID.glob); + } + + return formatter.formatter(value, args, variable); +} diff --git a/public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts b/public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts deleted file mode 100644 index 22214be1c5f..00000000000 --- a/public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { SceneObjectBase } from '../core/SceneObjectBase'; -import { SceneObjectStatePlain } from '../core/types'; - -import { sceneTemplateInterpolator } from './sceneTemplateInterpolator'; -import { SceneVariableSet } from './sets/SceneVariableSet'; -import { ConstantVariable } from './variants/ConstantVariable'; -import { ObjectVariable } from './variants/ObjectVariable'; - -interface TestSceneState extends SceneObjectStatePlain { - nested?: TestScene; -} - -class TestScene extends SceneObjectBase {} - -describe('sceneTemplateInterpolator', () => { - it('Should be interpolate and use closest variable', () => { - const scene = new TestScene({ - $variables: new SceneVariableSet({ - variables: [ - new ConstantVariable({ - name: 'test', - value: 'hello', - }), - new ConstantVariable({ - name: 'atRootOnly', - value: 'RootValue', - }), - ], - }), - nested: new TestScene({ - $variables: new SceneVariableSet({ - variables: [ - new ConstantVariable({ - name: 'test', - value: 'nestedValue', - }), - ], - }), - }), - }); - - expect(sceneTemplateInterpolator('${test}', scene)).toBe('hello'); - expect(sceneTemplateInterpolator('${test}', scene.state.nested!)).toBe('nestedValue'); - expect(sceneTemplateInterpolator('${atRootOnly}', scene.state.nested!)).toBe('RootValue'); - }); - - describe('Given an expression with fieldPath', () => { - it('Should interpolate correctly', () => { - const scene = new TestScene({ - $variables: new SceneVariableSet({ - variables: [ - new ObjectVariable({ - name: 'test', - value: { prop1: 'prop1Value' }, - }), - ], - }), - }); - - expect(sceneTemplateInterpolator('${test.prop1}', scene)).toBe('prop1Value'); - }); - }); -}); diff --git a/public/app/features/scenes/variables/sceneTemplateInterpolator.ts b/public/app/features/scenes/variables/sceneTemplateInterpolator.ts deleted file mode 100644 index 716b95ed703..00000000000 --- a/public/app/features/scenes/variables/sceneTemplateInterpolator.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isArray } from 'lodash'; - -import { variableRegex } from 'app/features/variables/utils'; - -import { SceneObject } from '../core/types'; - -import { SceneVariable } from './types'; - -export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) { - // Skip any interpolation if there are no variables in the scene object graph - if (!sceneObject.getVariables()) { - return target; - } - - variableRegex.lastIndex = 0; - - return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { - const variableName = var1 || var2 || var3; - const variable = lookupSceneVariable(variableName, sceneObject); - - if (!variable) { - return match; - } - - const value = variable.getValue(fieldPath); - - if (isArray(value)) { - return 'not supported yet'; - } - - return String(value); - }); -} - -function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { - const variables = sceneObject.state.$variables; - if (!variables) { - if (sceneObject.parent) { - return lookupSceneVariable(name, sceneObject.parent); - } else { - return null; - } - } - - const found = variables.getByName(name); - if (found) { - return found; - } else if (sceneObject.parent) { - return lookupSceneVariable(name, sceneObject.parent); - } - - return null; -} diff --git a/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx b/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx index 98954ecabf2..c5898267f9b 100644 --- a/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx +++ b/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx @@ -72,7 +72,7 @@ describe('SceneVariableList', () => { C.signalUpdateCompleted(); // When changing A should start B but not C (yet) - A.onSingleValueChange({ value: 'AB', text: 'AB' }); + A.changeValueTo('AB'); expect(B.state.loading).toBe(true); expect(C.state.loading).toBe(false); @@ -125,7 +125,7 @@ describe('SceneVariableList', () => { expect((sceneObjectWithVariable as any)._renderCount).toBe(2); act(() => { - B.onSingleValueChange({ value: 'B', text: 'B' }); + B.changeValueTo('B'); }); expect(screen.getByText('AA - B')).toBeInTheDocument(); diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts index 5cb9dafa629..a2ac42835fe 100644 --- a/public/app/features/scenes/variables/types.ts +++ b/public/app/features/scenes/variables/types.ts @@ -24,7 +24,7 @@ export interface SceneVariable): void; } diff --git a/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts b/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts index fe7c5ddc457..2df3a4dcb09 100644 --- a/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts +++ b/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts @@ -1,6 +1,8 @@ import { lastValueFrom, Observable, of } from 'rxjs'; -import { VariableValueOption } from '../types'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; + +import { SceneVariableValueChangedEvent, VariableValueOption } from '../types'; import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../variants/MultiValueVariable'; export interface ExampleVariableState extends MultiValueVariableState { @@ -47,5 +49,108 @@ describe('MultiValueVariable', () => { expect(variable.state.value).toBe('A'); expect(variable.state.text).toBe('A'); }); + + it('Should maintain the valid values when multiple selected', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + isMulti: true, + optionsToReturn: [ + { label: 'A', value: 'A' }, + { label: 'C', value: 'C' }, + ], + value: ['A', 'B', 'C'], + text: ['A', 'B', 'C'], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(['A', 'C']); + expect(variable.state.text).toEqual(['A', 'C']); + }); + + it('Should pick first option if none of the current values are valid', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + isMulti: true, + optionsToReturn: [ + { label: 'A', value: 'A' }, + { label: 'C', value: 'C' }, + ], + value: ['D', 'E'], + text: ['E', 'E'], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(['A']); + expect(variable.state.text).toEqual(['A']); + }); + + it('Should handle $__all value and send change event even when value is still $__all', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + optionsToReturn: [ + { label: 'A', value: '1' }, + { label: 'B', value: '2' }, + ], + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + }); + + let changeEvent: SceneVariableValueChangedEvent | undefined; + variable.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => (changeEvent = evt)); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toBe(ALL_VARIABLE_VALUE); + expect(variable.state.text).toBe(ALL_VARIABLE_TEXT); + expect(variable.state.options).toEqual(variable.state.optionsToReturn); + expect(changeEvent).toBeDefined(); + }); + }); + + describe('getValue and getValueText', () => { + it('GetValueText should return text', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + optionsToReturn: [], + value: '1', + text: 'A', + }); + + expect(variable.getValue()).toBe('1'); + expect(variable.getValueText()).toBe('A'); + }); + + it('GetValueText should return All text when value is $__all', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + optionsToReturn: [], + value: ALL_VARIABLE_VALUE, + text: 'A', + }); + + expect(variable.getValueText()).toBe(ALL_VARIABLE_TEXT); + }); + + it('GetValue should return all options as an array when value is $__all', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [ + { label: 'A', value: '1' }, + { label: 'B', value: '2' }, + ], + optionsToReturn: [], + value: ALL_VARIABLE_VALUE, + text: 'A', + }); + + expect(variable.getValue()).toEqual(['1', '2']); + }); }); }); diff --git a/public/app/features/scenes/variables/variants/MultiValueVariable.ts b/public/app/features/scenes/variables/variants/MultiValueVariable.ts index 54519f4959c..941865b9bf4 100644 --- a/public/app/features/scenes/variables/variants/MultiValueVariable.ts +++ b/public/app/features/scenes/variables/variants/MultiValueVariable.ts @@ -1,6 +1,7 @@ +import { isEqual } from 'lodash'; import { map, Observable } from 'rxjs'; -import { SelectableValue } from '@grafana/data'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneObject } from '../../core/types'; @@ -14,8 +15,8 @@ import { } from '../types'; export interface MultiValueVariableState extends SceneVariableState { - value: string | string[]; // old current.text - text: string | string[]; // old current.value + value: VariableValue; // old current.text + text: VariableValue; // old current.value options: VariableValueOption[]; isMulti?: boolean; } @@ -47,40 +48,92 @@ export abstract class MultiValueVariable = { + options, + loading: false, + value: this.state.value, + text: this.state.text, + }; + if (options.length === 0) { // TODO handle the no value state - this.setStateHelper({ value: '?', loading: false }); - return; + } else if (this.hasAllValue()) { + // If value is set to All then we keep it set to All but just store the options + } else if (this.state.isMulti) { + // If we are a multi valued variable validate the current values are among the options + const currentValues = Array.isArray(this.state.value) ? this.state.value : [this.state.value]; + const validValues = currentValues.filter((v) => options.find((o) => o.value === v)); + + // If no valid values pick the first option + if (validValues.length === 0) { + stateUpdate.value = [options[0].value]; + stateUpdate.text = [options[0].label]; + } + // We have valid values, if it's different from current valid values update current values + else if (!isEqual(validValues, this.state.value)) { + const validTexts = validValues.map((v) => options.find((o) => o.value === v)!.label); + stateUpdate.value = validValues; + stateUpdate.text = validTexts; + } + } else { + // Single valued variable + const foundCurrent = options.find((x) => x.value === this.state.value); + if (!foundCurrent) { + // Current value is not valid. Set to first of the available options + stateUpdate.value = options[0].value; + stateUpdate.text = options[0].label; + } } - const foundCurrent = options.find((x) => x.value === this.state.value); - if (!foundCurrent) { - // Current value is not valid. Set to first of the available options - this.changeValueAndPublishChangeEvent(options[0].value, options[0].label); - } else { - // current value is still ok - this.setStateHelper({ loading: false }); + // Remember current value and text + const { value: prevValue, text: prevText } = this.state; + + // Perform state change + this.setStateHelper(stateUpdate); + + // Publish value changed event only if value changed + if (stateUpdate.value !== prevValue || stateUpdate.text !== prevText || this.hasAllValue()) { + this.publishEvent(new SceneVariableValueChangedEvent(this), true); } } public getValue(): VariableValue { + if (this.hasAllValue()) { + return this.state.options.map((x) => x.value); + } + return this.state.value; } public getValueText(): string { + if (this.hasAllValue()) { + return ALL_VARIABLE_TEXT; + } + if (Array.isArray(this.state.text)) { return this.state.text.join(' + '); } - return this.state.text; + return String(this.state.text); } - private changeValueAndPublishChangeEvent(value: string | string[], text: string | string[]) { + private hasAllValue() { + const value = this.state.value; + return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); + } + + private setStateAndPublishValueChangedEvent(state: Partial) { + this.setStateHelper(state); + } + + /** + * Change the value and publish SceneVariableValueChangedEvent event + */ + public changeValueTo(value: VariableValue, text?: VariableValue) { if (value !== this.state.value || text !== this.state.text) { - this.setStateHelper({ value, text, loading: false }); + this.setStateAndPublishValueChangedEvent({ value, text, loading: false }); this.publishEvent(new SceneVariableValueChangedEvent(this), true); } } @@ -92,15 +145,4 @@ export abstract class MultiValueVariable = this; test.setState(state); } - - public onSingleValueChange = (value: SelectableValue) => { - this.changeValueAndPublishChangeEvent(value.value!, value.label!); - }; - - public onMultiValueChange = (value: Array>) => { - this.changeValueAndPublishChangeEvent( - value.map((v) => v.value!), - value.map((v) => v.label!) - ); - }; } diff --git a/public/app/features/scenes/variables/variants/TestVariable.tsx b/public/app/features/scenes/variables/variants/TestVariable.tsx index bca7cdbc719..a9ad24ec298 100644 --- a/public/app/features/scenes/variables/variants/TestVariable.tsx +++ b/public/app/features/scenes/variables/variants/TestVariable.tsx @@ -3,10 +3,10 @@ import { Observable, Subject } from 'rxjs'; import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree'; +import { sceneGraph } from '../../core/sceneGraph'; import { SceneComponentProps } from '../../core/types'; import { VariableDependencyConfig } from '../VariableDependencyConfig'; import { VariableValueSelect } from '../components/VariableValueSelect'; -import { sceneTemplateInterpolator } from '../sceneTemplateInterpolator'; import { VariableValueOption } from '../types'; import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable'; @@ -28,6 +28,17 @@ export class TestVariable extends MultiValueVariable { statePaths: ['query'], }); + public constructor(initialState: Partial) { + super({ + name: 'Test', + value: 'Value', + text: 'Text', + query: 'Query', + options: [], + ...initialState, + }); + } + public getValueOptions(args: VariableGetOptionsArgs): Observable { const { delayMs } = this.state; @@ -56,7 +67,7 @@ export class TestVariable extends MultiValueVariable { } private issueQuery() { - const interpolatedQuery = sceneTemplateInterpolator(this.state.query, this); + const interpolatedQuery = sceneGraph.interpolate(this, this.state.query); const options = queryMetricTree(interpolatedQuery).map((x) => ({ label: x.name, value: x.name })); this.setState({ diff --git a/public/app/plugins/datasource/testdata/metricTree.test.ts b/public/app/plugins/datasource/testdata/metricTree.test.ts index 7491f167c05..b94f8b9965b 100644 --- a/public/app/plugins/datasource/testdata/metricTree.test.ts +++ b/public/app/plugins/datasource/testdata/metricTree.test.ts @@ -14,11 +14,11 @@ describe('MetricTree', () => { it('queryMetric tree supports glob paths', () => { const nodes = queryMetricTree('A.{AB,AC}.*').map((i) => i.name); - expect(nodes).toEqual(['ABA', 'ABB', 'ABC', 'ACA', 'ACB', 'ACC']); + expect(nodes).toEqual(expect.arrayContaining(['ABA', 'ABB', 'ABC', 'ACA', 'ACB', 'ACC'])); }); it('queryMetric tree supports wildcard matching', () => { const nodes = queryMetricTree('A.AB.AB*').map((i) => i.name); - expect(nodes).toEqual(['ABA', 'ABB', 'ABC']); + expect(nodes).toEqual(expect.arrayContaining(['ABA', 'ABB', 'ABC'])); }); }); diff --git a/public/app/plugins/datasource/testdata/metricTree.ts b/public/app/plugins/datasource/testdata/metricTree.ts index 6b617d4dd3f..109982d9c34 100644 --- a/public/app/plugins/datasource/testdata/metricTree.ts +++ b/public/app/plugins/datasource/testdata/metricTree.ts @@ -16,7 +16,7 @@ export interface TreeNode { * ] */ function buildMetricTree(parent: string, depth: number): TreeNode[] { - const chars = ['A', 'B', 'C']; + const chars = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; const children: TreeNode[] = []; if (depth > 5) {