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 <dominik.prokop@grafana.com>
This commit is contained in:
Torkel Ödegaard 2022-11-16 11:36:30 +01:00 committed by GitHub
parent 2a9381e998
commit 8c585a4ebf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1157 additions and 315 deletions

View File

@ -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"]
],

View File

@ -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);
});
});
});

View File

@ -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');

View File

@ -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) => {

View File

@ -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<SceneCanvasTextState> {
return (
<div style={style} data-testid={key}>
{model.interpolate(text)}
{sceneGraph.interpolate(model, text)}
</div>
);
};

View File

@ -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<RepeatOptions> {
super.activate();
this._subs.add(
this.getData().subscribeToState({
sceneGraph.getData(this).subscribeToState({
next: (data) => {
if (data.data?.state === LoadingState.Done) {
this.performRepeat(data.data);

View File

@ -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<SceneTimePickerState> {
function SceneTimePickerRenderer({ model }: SceneComponentProps<SceneTimePicker>) {
const { hidePicker } = model.useState();
const timeRange = model.getTimeRange();
const timeRange = sceneGraph.getTimeRange(model);
const timeRangeState = timeRange.useState();
if (hidePicker) {

View File

@ -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<VizPanelState> {
});
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<VizPanelState> {
function ScenePanelRenderer({ model }: SceneComponentProps<VizPanel>) {
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 = <SceneDragHandle layoutKey={layout.state.key!} />;
const titleInterpolated = model.interpolate(title);
const titleInterpolated = sceneGraph.interpolate(model, title);
return (
<AutoSizer>

View File

@ -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<SceneGridRowState> {
function SceneGridRowRenderer({ model }: SceneComponentProps<SceneGridRow>) {
const styles = useStyles2(getSceneGridRowStyles);
const { isCollapsible, isCollapsed, isDraggable, title } = model.useState();
const layout = model.getLayout();
const layout = sceneGraph.getLayout(model);
const dragHandle = <SceneDragHandle layoutKey={layout.state.key!} />;
return (

View File

@ -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<T extends SceneObject>({
model,
@ -32,9 +30,29 @@ export function SceneComponentWrapper<T extends SceneObject>({
return inner;
}
return <SceneComponentEditingWrapper model={model}>{inner}</SceneComponentEditingWrapper>;
const editor = getSceneEditor(model);
const EditWrapper = getSceneEditor(model).getEditComponentWrapper();
return (
<EditWrapper model={model} editor={editor}>
{inner}
</EditWrapper>
);
}
function EmptyRenderer<T>(_: SceneComponentProps<T>): 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');
}

View File

@ -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<TState extends SceneObjectState = SceneObjectState>
@ -185,81 +176,6 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
return useSceneObjectState(this);
}
/**
* Will walk up the scene object graph to the closest $timeRange scene object
*/
public getTimeRange(): SceneTimeRange {
const { $timeRange } = this.state;
if ($timeRange) {
return $timeRange;
}
if (this.parent) {
return this.parent.getTimeRange();
}
throw new Error('No time range found in scene tree');
}
/**
* Will walk up the scene object graph to the closest $data scene object
*/
public getData(): SceneObject<SceneDataState> {
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<SceneLayoutState> {
if (this.constructor.name === 'SceneFlexLayout' || this.constructor.name === 'SceneGridLayout') {
return this as SceneObject<SceneLayoutState>;
}
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<TState extends SceneObjectState = SceneObj
public clone(withState?: Partial<TState>): 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);
}
}
/**

View File

@ -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<SceneDataState> {
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<SceneLayoutState> {
if (scene.constructor.name === 'SceneFlexLayout' || scene.constructor.name === 'SceneGridLayout') {
return scene as SceneObject<SceneLayoutState>;
}
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,
};

View File

@ -86,24 +86,9 @@ export interface SceneObject<TState extends SceneObjectState = SceneObjectState>
/** 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<SceneDataState>;
/** 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<SceneLayoutState>;
/** Returns a deep clone this object and all its children */
clone(state?: Partial<TState>): this;
@ -134,6 +119,13 @@ export interface SceneEditor extends SceneObject<SceneEditorState> {
onMouseEnterObject(model: SceneObject): void;
onMouseLeaveObject(model: SceneObject): void;
onSelectObject(model: SceneObject): void;
getEditComponentWrapper(): React.ComponentType<SceneComponentEditWrapperProps>;
}
interface SceneComponentEditWrapperProps {
editor: SceneEditor;
model: SceneObject;
children: React.ReactNode;
}
export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {}

View File

@ -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<T extends SceneObject>({
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);

View File

@ -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<SceneEditorState> implemen
public onSelectObject(model: SceneObject) {
this.setState({ selectedObject: { ref: model } });
}
public getEditComponentWrapper() {
return SceneComponentEditWrapper;
}
}
function SceneEditorRenderer({ model, isEditing }: SceneComponentProps<SceneEditManager>) {

View File

@ -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 (
<div className={styles.node}>

View File

@ -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<QueryRunnerState> {
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<QueryRunnerState> {
}
public runQueries() {
const timeRange = this.getTimeRange();
const timeRange = sceneGraph.getTimeRange(this);
this.runWithTimeRange(timeRange.state);
}

View File

@ -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',
}),

View File

@ -19,7 +19,13 @@ export function VariableValueSelect({ model }: SceneComponentProps<MultiValueVar
allowCustomValue
isLoading={loading}
options={options}
onChange={model.onMultiValueChange}
closeMenuOnSelect={false}
onChange={(newValue) => {
model.changeValueTo(
newValue.map((v) => v.value!),
newValue.map((v) => v.label!)
);
}}
/>
);
}
@ -33,7 +39,9 @@ export function VariableValueSelect({ model }: SceneComponentProps<MultiValueVar
allowCustomValue
isLoading={loading}
options={options}
onChange={model.onSingleValueChange}
onChange={(newValue) => {
model.changeValueTo(newValue.value!, newValue.label!);
}}
/>
);
}

View File

@ -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<SceneObjectStatePlai
}
function VariableValueSelectorsRenderer({ model }: SceneComponentProps<VariableValueSelectors>) {
const variables = model.getVariables()!.useState();
const variables = sceneGraph.getVariables(model)!.useState();
return (
<>

View File

@ -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<ScopedVarsProxyVariableState>
implements SceneVariable<ScopedVarsProxyVariableState>
{
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;
}

View File

@ -0,0 +1,68 @@
import { VariableValue } from '../types';
import { TestVariable } from '../variants/TestVariable';
import { formatRegistry, FormatRegistryID } from './formatRegistry';
function formatValue<T extends VariableValue>(
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, '<script>alert(asd)</script>')).toBe(
'&lt;script&gt;alert(asd)&lt;/script&gt;'
);
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');
});
});

View File

@ -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<FormatRegistryItem>(() => {
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 (<value>|<value>) 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);
}

View File

@ -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<TestSceneState> {}
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');
});
});

View File

@ -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);
}

View File

@ -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<TestSceneState> {}
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');
});
});
});

View File

@ -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;
}

View File

@ -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();

View File

@ -24,7 +24,7 @@ export interface SceneVariable<TState extends SceneVariableState = SceneVariable
/**
* Should return the value for the given field path
*/
getValue(fieldPath?: string): VariableValue;
getValue(fieldPath?: string): VariableValue | undefined | null;
/**
* Should return the value display text, used by the "text" formatter
@ -34,7 +34,9 @@ export interface SceneVariable<TState extends SceneVariableState = SceneVariable
getValueText?(): string;
}
export type VariableValue = string | string[] | number | number[] | boolean | boolean[] | null | undefined;
export type VariableValue = VariableValueSingle | VariableValueSingle[];
export type VariableValueSingle = string | boolean | number;
export interface ValidateAndUpdateResult {}
export interface VariableValueOption {
@ -62,8 +64,7 @@ export interface SceneVariableDependencyConfigLike {
hasDependencyOn(name: string): boolean;
/**
* Will be called when any variable value has changed, not just variable names returned by getNames().
* It is up the implementation of this interface to filter it by actual dependencies.
* Will be called when any variable value has changed.
**/
variableValuesChanged(variables: Set<SceneVariable>): void;
}

View File

@ -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']);
});
});
});

View File

@ -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<TState extends MultiValueVariableState
/**
* Check if current value is valid given new options. If not update the value.
* TODO: Handle multi valued variables
*/
private updateValueGivenNewOptions(options: VariableValueOption[]) {
const stateUpdate: Partial<MultiValueVariableState> = {
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<MultiValueVariableState>) {
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<TState extends MultiValueVariableState
const test: SceneObject<MultiValueVariableState> = this;
test.setState(state);
}
public onSingleValueChange = (value: SelectableValue<string>) => {
this.changeValueAndPublishChangeEvent(value.value!, value.label!);
};
public onMultiValueChange = (value: Array<SelectableValue<string>>) => {
this.changeValueAndPublishChangeEvent(
value.map((v) => v.value!),
value.map((v) => v.label!)
);
};
}

View File

@ -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<TestVariableState> {
statePaths: ['query'],
});
public constructor(initialState: Partial<TestVariableState>) {
super({
name: 'Test',
value: 'Value',
text: 'Text',
query: 'Query',
options: [],
...initialState,
});
}
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
const { delayMs } = this.state;
@ -56,7 +67,7 @@ export class TestVariable extends MultiValueVariable<TestVariableState> {
}
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({

View File

@ -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']));
});
});

View File

@ -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) {