mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
2a9381e998
commit
8c585a4ebf
@ -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"]
|
||||
],
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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 (
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
120
public/app/features/scenes/core/sceneGraph.ts
Normal file
120
public/app/features/scenes/core/sceneGraph.ts
Normal 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,
|
||||
};
|
@ -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 {}
|
||||
|
@ -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);
|
||||
|
@ -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>) {
|
||||
|
@ -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}>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
}),
|
||||
|
@ -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!);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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;
|
||||
}
|
@ -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(
|
||||
'<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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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!)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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']));
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user