mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Variables: SceneVariable update process (#57784)
* First baby steps * First baby steps * No progress really * Updates * no progress * refactoring * Progress on sub menu and value selectors * Some more tweaks * Lots of progress * Progress * Updates * Progress * Tweaks * Updates * Updates to variable system * Cleaner tests * Update * Some cleanup * correct test name * Renames and moves * prop rename * Fixed scene template interpolator * More tests for SceneObjectBase and fixed issue in EventBus * Updates * More tweaks * More refinements * Fixed test * Added test to EventBus * Clone all scene object arrays * Simplify * tried to merge issue * Update * added more comments to interface * temp progress * Trying to simplify things, but struggling a bit * Updated * Tweaks * Progress on fixing the select componenet and typing, and sharing code in a base class * Updated * Multi select * Simpler loading state * Update * removed failOnConsole * Removed old funcs * Moved logic from update manage to MultiValueVariable * Added tests for MultiValueVariable logic * Made value a more abstract concept to support object values * renamed func to getValueText * Refactored and moved logic to VariableSet * Added test for deactivation and query cancelling * Tweaks * Fixed lint issues
This commit is contained in:
parent
c646ff0ce3
commit
6ed35292fe
@ -14,6 +14,7 @@ interface SceneState extends SceneObjectStatePlain {
|
||||
title: string;
|
||||
layout: SceneObject;
|
||||
actions?: SceneObject[];
|
||||
subMenu?: SceneObject;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
@ -33,7 +34,7 @@ export class Scene extends SceneObjectBase<SceneState> {
|
||||
}
|
||||
|
||||
function SceneRenderer({ model }: SceneComponentProps<Scene>) {
|
||||
const { title, layout, actions = [], isEditing, $editor } = model.useState();
|
||||
const { title, layout, actions = [], isEditing, $editor, subMenu } = model.useState();
|
||||
|
||||
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
|
||||
|
||||
@ -55,9 +56,12 @@ function SceneRenderer({ model }: SceneComponentProps<Scene>) {
|
||||
|
||||
return (
|
||||
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Canvas} toolbar={pageToolbar}>
|
||||
<div style={{ flexGrow: 1, display: 'flex', gap: '8px', overflow: 'auto' }}>
|
||||
<layout.Component model={layout} isEditing={isEditing} />
|
||||
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />}
|
||||
<div style={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{subMenu && <subMenu.Component model={subMenu} />}
|
||||
<div style={{ flexGrow: 1, display: 'flex', gap: '8px', overflow: 'auto' }}>
|
||||
<layout.Component model={layout} isEditing={isEditing} />
|
||||
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
|
@ -4,6 +4,7 @@ import { Field, Input } from '@grafana/ui';
|
||||
|
||||
import { SceneObjectBase } from '../core/SceneObjectBase';
|
||||
import { SceneComponentProps, SceneLayoutChildState } from '../core/types';
|
||||
import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator';
|
||||
|
||||
export interface SceneCanvasTextState extends SceneLayoutChildState {
|
||||
text: string;
|
||||
@ -13,8 +14,10 @@ export interface SceneCanvasTextState extends SceneLayoutChildState {
|
||||
|
||||
export class SceneCanvasText extends SceneObjectBase<SceneCanvasTextState> {
|
||||
public static Editor = Editor;
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<SceneCanvasText>) => {
|
||||
const { text, fontSize = 20, align = 'left' } = model.useState();
|
||||
const textInterpolated = sceneTemplateInterpolator(text, model);
|
||||
|
||||
const style: CSSProperties = {
|
||||
fontSize: fontSize,
|
||||
@ -25,7 +28,7 @@ export class SceneCanvasText extends SceneObjectBase<SceneCanvasTextState> {
|
||||
justifyContent: align,
|
||||
};
|
||||
|
||||
return <div style={style}>{text}</div>;
|
||||
return <div style={style}>{textInterpolated}</div>;
|
||||
};
|
||||
}
|
||||
|
||||
|
22
public/app/features/scenes/components/SceneSubMenu.tsx
Normal file
22
public/app/features/scenes/components/SceneSubMenu.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SceneObjectBase } from '../core/SceneObjectBase';
|
||||
import { SceneLayoutState, SceneComponentProps } from '../core/types';
|
||||
|
||||
interface SceneSubMenuState extends SceneLayoutState {}
|
||||
|
||||
export class SceneSubMenu extends SceneObjectBase<SceneSubMenuState> {
|
||||
public static Component = SceneSubMenuRenderer;
|
||||
}
|
||||
|
||||
function SceneSubMenuRenderer({ model }: SceneComponentProps<SceneSubMenu>) {
|
||||
const { children } = model.useState();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
{children.map((child) => (
|
||||
<child.Component key={child.state.key} model={child} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { SceneVariableSet } from '../variables/SceneVariableSet';
|
||||
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
|
||||
|
||||
import { SceneDataNode } from './SceneDataNode';
|
||||
import { SceneObjectBase } from './SceneObjectBase';
|
||||
|
@ -9,7 +9,9 @@ import { SceneComponentWrapper } from './SceneComponentWrapper';
|
||||
import { SceneObjectStateChangedEvent } from './events';
|
||||
import { SceneDataState, SceneObject, SceneComponent, SceneEditor, SceneTimeRange, SceneObjectState } from './types';
|
||||
|
||||
export abstract class SceneObjectBase<TState extends SceneObjectState = {}> implements SceneObject<TState> {
|
||||
export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObjectState>
|
||||
implements SceneObject<TState>
|
||||
{
|
||||
private _isActive = false;
|
||||
private _subject = new Subject<TState>();
|
||||
private _state: TState;
|
||||
|
@ -114,6 +114,7 @@ export interface SceneEditor extends SceneObject<SceneEditorState> {
|
||||
}
|
||||
|
||||
export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {}
|
||||
|
||||
export interface SceneTimeRange extends SceneObject<SceneTimeRangeState> {
|
||||
onTimeRangeChange(timeRange: TimeRange): void;
|
||||
onIntervalChanged(interval: string): void;
|
||||
|
@ -3,9 +3,10 @@ import { Scene } from '../components/Scene';
|
||||
import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo';
|
||||
import { getNestedScene } from './nested';
|
||||
import { getSceneWithRows } from './sceneWithRows';
|
||||
import { getVariablesDemo } from './variablesDemo';
|
||||
|
||||
export function getScenes(): Scene[] {
|
||||
return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows()];
|
||||
return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows(), getVariablesDemo()];
|
||||
}
|
||||
|
||||
const cache: Record<string, Scene> = {};
|
||||
|
63
public/app/features/scenes/scenes/variablesDemo.tsx
Normal file
63
public/app/features/scenes/scenes/variablesDemo.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { getDefaultTimeRange } from '@grafana/data';
|
||||
|
||||
import { Scene } from '../components/Scene';
|
||||
import { SceneCanvasText } from '../components/SceneCanvasText';
|
||||
import { SceneFlexLayout } from '../components/SceneFlexLayout';
|
||||
import { SceneSubMenu } from '../components/SceneSubMenu';
|
||||
import { SceneTimePicker } from '../components/SceneTimePicker';
|
||||
import { SceneTimeRange } from '../core/SceneTimeRange';
|
||||
import { VariableValueSelectors } from '../variables/components/VariableValueSelectors';
|
||||
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
|
||||
import { TestVariable } from '../variables/variants/TestVariable';
|
||||
|
||||
export function getVariablesDemo(): Scene {
|
||||
const scene = new Scene({
|
||||
title: 'Variables',
|
||||
layout: new SceneFlexLayout({
|
||||
direction: 'row',
|
||||
children: [
|
||||
new SceneCanvasText({
|
||||
text: 'Some text with a variable: ${server} - ${pod}',
|
||||
fontSize: 40,
|
||||
align: 'center',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new TestVariable({
|
||||
name: 'server',
|
||||
query: 'A.*',
|
||||
value: 'server',
|
||||
text: '',
|
||||
delayMs: 1000,
|
||||
options: [],
|
||||
}),
|
||||
new TestVariable({
|
||||
name: 'pod',
|
||||
query: 'A.$server.*',
|
||||
value: 'pod',
|
||||
delayMs: 1000,
|
||||
text: '',
|
||||
options: [],
|
||||
}),
|
||||
new TestVariable({
|
||||
name: 'handler',
|
||||
query: 'A.$server.$pod.*',
|
||||
value: 'handler',
|
||||
delayMs: 1000,
|
||||
isMulti: true,
|
||||
text: '',
|
||||
options: [],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
|
||||
actions: [new SceneTimePicker({})],
|
||||
subMenu: new SceneSubMenu({
|
||||
children: [new VariableValueSelectors({})],
|
||||
}),
|
||||
});
|
||||
|
||||
return scene;
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import { isArray } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { Select, MultiSelect } from '@grafana/ui';
|
||||
|
||||
import { SceneComponentProps } from '../../core/types';
|
||||
import { MultiValueVariable } from '../variants/MultiValueVariable';
|
||||
|
||||
export function VariableValueSelect({ model }: SceneComponentProps<MultiValueVariable>) {
|
||||
const { value, key, loading, isMulti, options } = model.useState();
|
||||
|
||||
if (isMulti) {
|
||||
return (
|
||||
<MultiSelect
|
||||
id={key}
|
||||
placeholder="Select value"
|
||||
width="auto"
|
||||
value={isArray(value) ? value : [value]}
|
||||
allowCustomValue
|
||||
isLoading={loading}
|
||||
options={options}
|
||||
onChange={model.onMultiValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
id={key}
|
||||
placeholder="Select value"
|
||||
width="auto"
|
||||
value={value}
|
||||
allowCustomValue
|
||||
isLoading={loading}
|
||||
options={options}
|
||||
onChange={model.onSingleValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
import { VariableHide } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
import { SceneObjectBase } from '../../core/SceneObjectBase';
|
||||
import { SceneComponentProps, SceneObject, SceneObjectStatePlain } from '../../core/types';
|
||||
import { SceneVariables, SceneVariableState } from '../types';
|
||||
|
||||
export class VariableValueSelectors extends SceneObjectBase<SceneObjectStatePlain> {
|
||||
public static Component = VariableValueSelectorsRenderer;
|
||||
}
|
||||
|
||||
function VariableValueSelectorsRenderer({ model }: SceneComponentProps<VariableValueSelectors>) {
|
||||
const variables = getVariables(model).useState();
|
||||
|
||||
return (
|
||||
<>
|
||||
{variables.variables.map((variable) => (
|
||||
<VariableValueSelectWrapper key={variable.state.key} variable={variable} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getVariables(model: SceneObject): SceneVariables {
|
||||
if (model.state.$variables) {
|
||||
return model.state.$variables;
|
||||
}
|
||||
|
||||
if (model.parent) {
|
||||
return getVariables(model.parent);
|
||||
}
|
||||
|
||||
throw new Error('No variables found');
|
||||
}
|
||||
|
||||
function VariableValueSelectWrapper({ variable }: { variable: SceneObject<SceneVariableState> }) {
|
||||
const state = variable.useState();
|
||||
|
||||
if (state.hide === VariableHide.hideVariable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-form">
|
||||
<VariableLabel state={state} />
|
||||
<variable.Component model={variable} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableLabel({ state }: { state: SceneVariableState }) {
|
||||
if (state.hide === VariableHide.hideLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elementId = `var-${state.key}`;
|
||||
const labelOrName = state.label ?? state.name;
|
||||
|
||||
if (state.description) {
|
||||
return (
|
||||
<Tooltip content={state.description} placement={'bottom'}>
|
||||
<label
|
||||
className="gf-form-label gf-form-label--variable"
|
||||
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
|
||||
htmlFor={elementId}
|
||||
>
|
||||
{labelOrName}
|
||||
</label>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className="gf-form-label gf-form-label--variable"
|
||||
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
|
||||
htmlFor={elementId}
|
||||
>
|
||||
{labelOrName}
|
||||
</label>
|
||||
);
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { getVariableDependencies } from './getVariableDependencies';
|
||||
|
||||
describe('getVariableDependencies', () => {
|
||||
it('Can get dependencies', () => {
|
||||
expect(getVariableDependencies('test.$plain ${withcurly} ${withformat:csv} [[deprecated]]')).toEqual([
|
||||
'plain',
|
||||
'withcurly',
|
||||
'withformat',
|
||||
'deprecated',
|
||||
]);
|
||||
});
|
||||
});
|
@ -0,0 +1,20 @@
|
||||
import { variableRegex } from 'app/features/variables/utils';
|
||||
|
||||
export function getVariableDependencies(stringToCheck: string): string[] {
|
||||
variableRegex.lastIndex = 0;
|
||||
|
||||
const matches = stringToCheck.matchAll(variableRegex);
|
||||
if (!matches) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dependencies: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
const [, var1, var2, , var3] = match;
|
||||
const variableName = var1 || var2 || var3;
|
||||
dependencies.push(variableName);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { SceneObjectBase } from '../core/SceneObjectBase';
|
||||
import { SceneObjectStatePlain } from '../core/types';
|
||||
|
||||
import { sceneTemplateInterpolator, SceneVariableSet, TextBoxSceneVariable } from './SceneVariableSet';
|
||||
import { sceneTemplateInterpolator } from './sceneTemplateInterpolator';
|
||||
import { SceneVariableSet } from './sets/SceneVariableSet';
|
||||
import { ConstantVariable } from './variants/ConstantVariable';
|
||||
|
||||
interface TestSceneState extends SceneObjectStatePlain {
|
||||
nested?: TestScene;
|
||||
@ -9,27 +11,27 @@ interface TestSceneState extends SceneObjectStatePlain {
|
||||
|
||||
class TestScene extends SceneObjectBase<TestSceneState> {}
|
||||
|
||||
describe('SceneObject with variables', () => {
|
||||
describe('sceneTemplateInterpolator', () => {
|
||||
it('Should be interpolate and use closest variable', () => {
|
||||
const scene = new TestScene({
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new TextBoxSceneVariable({
|
||||
new ConstantVariable({
|
||||
name: 'test',
|
||||
current: { value: 'hello' },
|
||||
value: 'hello',
|
||||
}),
|
||||
new TextBoxSceneVariable({
|
||||
new ConstantVariable({
|
||||
name: 'atRootOnly',
|
||||
current: { value: 'RootValue' },
|
||||
value: 'RootValue',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
nested: new TestScene({
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new TextBoxSceneVariable({
|
||||
new ConstantVariable({
|
||||
name: 'test',
|
||||
current: { value: 'nestedValue' },
|
||||
value: 'nestedValue',
|
||||
}),
|
||||
],
|
||||
}),
|
@ -1,18 +1,10 @@
|
||||
import { isArray } from 'lodash';
|
||||
|
||||
import { variableRegex } from 'app/features/variables/utils';
|
||||
|
||||
import { SceneObjectBase } from '../core/SceneObjectBase';
|
||||
import { SceneObject } from '../core/types';
|
||||
|
||||
import { SceneVariable, SceneVariables, SceneVariableSetState, SceneVariableState } from './types';
|
||||
|
||||
export class TextBoxSceneVariable extends SceneObjectBase<SceneVariableState> implements SceneVariable {}
|
||||
|
||||
export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> implements SceneVariables {
|
||||
public getVariableByName(name: string): SceneVariable | undefined {
|
||||
// TODO: Replace with index
|
||||
return this.state.variables.find((x) => x.state.name === name);
|
||||
}
|
||||
}
|
||||
import { SceneVariable } from './types';
|
||||
|
||||
export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) {
|
||||
variableRegex.lastIndex = 0;
|
||||
@ -25,7 +17,13 @@ export function sceneTemplateInterpolator(target: string, sceneObject: SceneObje
|
||||
return match;
|
||||
}
|
||||
|
||||
return variable.state.current.value;
|
||||
const value = variable.getValue(fieldPath);
|
||||
|
||||
if (isArray(value)) {
|
||||
return 'not supported yet';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
@ -39,7 +37,7 @@ function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVaria
|
||||
}
|
||||
}
|
||||
|
||||
const found = variables.getVariableByName(name);
|
||||
const found = variables.getByName(name);
|
||||
if (found) {
|
||||
return found;
|
||||
} else if (sceneObject.parent) {
|
@ -0,0 +1,95 @@
|
||||
import { SceneObjectBase } from '../../core/SceneObjectBase';
|
||||
import { SceneObjectStatePlain } from '../../core/types';
|
||||
import { TestVariable } from '../variants/TestVariable';
|
||||
|
||||
import { SceneVariableSet } from './SceneVariableSet';
|
||||
|
||||
interface TestSceneState extends SceneObjectStatePlain {
|
||||
nested?: TestScene;
|
||||
}
|
||||
|
||||
class TestScene extends SceneObjectBase<TestSceneState> {}
|
||||
|
||||
describe('SceneVariableList', () => {
|
||||
describe('When activated', () => {
|
||||
it('Should update variables in dependency order', async () => {
|
||||
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
|
||||
const B = new TestVariable({ name: 'B', query: 'A.$A', value: '', text: '', options: [] });
|
||||
const C = new TestVariable({ name: 'C', query: 'A.$A.$B.*', value: '', text: '', options: [] });
|
||||
|
||||
const scene = new TestScene({
|
||||
$variables: new SceneVariableSet({ variables: [C, B, A] }),
|
||||
});
|
||||
|
||||
scene.activate();
|
||||
|
||||
// Should start variables with no dependencies
|
||||
expect(A.state.loading).toBe(true);
|
||||
expect(B.state.loading).toBe(undefined);
|
||||
expect(C.state.loading).toBe(undefined);
|
||||
|
||||
// When A complete should start B
|
||||
A.signalUpdateCompleted();
|
||||
expect(A.state.value).toBe('AA');
|
||||
expect(A.state.issuedQuery).toBe('A.*');
|
||||
expect(A.state.loading).toBe(false);
|
||||
expect(B.state.loading).toBe(true);
|
||||
|
||||
// Should wait with C as B is not completed yet
|
||||
expect(C.state.loading).toBe(undefined);
|
||||
|
||||
// When B completes should now start C
|
||||
B.signalUpdateCompleted();
|
||||
expect(B.state.loading).toBe(false);
|
||||
expect(C.state.loading).toBe(true);
|
||||
|
||||
// When C completes issue correct interpolated query containing the new values for A and B
|
||||
C.signalUpdateCompleted();
|
||||
expect(C.state.issuedQuery).toBe('A.AA.AAA.*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('When variable changes value', () => {
|
||||
it('Should start update process', async () => {
|
||||
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
|
||||
const B = new TestVariable({ name: 'B', query: 'A.$A.*', value: '', text: '', options: [] });
|
||||
const C = new TestVariable({ name: 'C', query: 'A.$A.$B.*', value: '', text: '', options: [] });
|
||||
|
||||
const scene = new TestScene({
|
||||
$variables: new SceneVariableSet({ variables: [C, B, A] }),
|
||||
});
|
||||
|
||||
scene.activate();
|
||||
|
||||
A.signalUpdateCompleted();
|
||||
B.signalUpdateCompleted();
|
||||
C.signalUpdateCompleted();
|
||||
|
||||
// When changing A should start B but not C (yet)
|
||||
A.onSingleValueChange({ value: 'AB', text: 'AB' });
|
||||
|
||||
expect(B.state.loading).toBe(true);
|
||||
expect(C.state.loading).toBe(false);
|
||||
|
||||
B.signalUpdateCompleted();
|
||||
expect(B.state.value).toBe('ABA');
|
||||
expect(C.state.loading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When deactivated', () => {
|
||||
it('Should cancel running variable queries', async () => {
|
||||
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
|
||||
|
||||
const scene = new TestScene({
|
||||
$variables: new SceneVariableSet({ variables: [A] }),
|
||||
});
|
||||
|
||||
scene.activate();
|
||||
expect(A.isGettingValues).toBe(true);
|
||||
|
||||
scene.deactivate();
|
||||
expect(A.isGettingValues).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
157
public/app/features/scenes/variables/sets/SceneVariableSet.ts
Normal file
157
public/app/features/scenes/variables/sets/SceneVariableSet.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { SceneObjectBase } from '../../core/SceneObjectBase';
|
||||
import { SceneVariable, SceneVariables, SceneVariableSetState, SceneVariableValueChangedEvent } from '../types';
|
||||
|
||||
export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> implements SceneVariables {
|
||||
/** Variables that have changed in since the activation or since the first manual value change */
|
||||
// private variablesThatHaveChanged = new Map<string, SceneVariable>();
|
||||
|
||||
/** Variables that are scheduled to be validated and updated */
|
||||
private variablesToUpdate = new Map<string, SceneVariable>();
|
||||
|
||||
/** Cached variable dependencies */
|
||||
private dependencies = new Map<string, string[]>();
|
||||
|
||||
/** Variables currently updating */
|
||||
private updating = new Map<string, VariableUpdateInProgress>();
|
||||
|
||||
public getByName(name: string): SceneVariable | undefined {
|
||||
// TODO: Replace with index
|
||||
return this.state.variables.find((x) => x.state.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to child variable value changes
|
||||
* And starts the variable value validation process
|
||||
*/
|
||||
public activate(): void {
|
||||
super.activate();
|
||||
|
||||
// Subscribe to changes to child variables
|
||||
this._subs.add(this.subscribeToEvent(SceneVariableValueChangedEvent, this.onVariableValueChanged));
|
||||
this.validateAndUpdateAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all currently running updates
|
||||
*/
|
||||
public deactivate(): void {
|
||||
super.deactivate();
|
||||
this.variablesToUpdate.clear();
|
||||
|
||||
for (const update of this.updating.values()) {
|
||||
update.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This loops through variablesToUpdate and update all that that can.
|
||||
* If one has a dependency that is currently in variablesToUpdate it will be skipped for now.
|
||||
*/
|
||||
private updateNextBatch() {
|
||||
for (const [name, variable] of this.variablesToUpdate) {
|
||||
if (!variable.validateAndUpdate) {
|
||||
throw new Error('Variable added to variablesToUpdate but does not have validateAndUpdate');
|
||||
}
|
||||
|
||||
// Wait for variables that has dependencies that also needs updates
|
||||
if (this.hasDependendencyInUpdateQueue(variable)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.updating.set(name, {
|
||||
variable,
|
||||
subscription: variable.validateAndUpdate().subscribe({
|
||||
next: () => this.validateAndUpdateCompleted(variable),
|
||||
error: (err) => this.handleVariableError(variable, err),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable has completed it's update process. This could mean that variables that depend on it can now be updated in turn.
|
||||
*/
|
||||
private validateAndUpdateCompleted(variable: SceneVariable) {
|
||||
const update = this.updating.get(variable.state.name);
|
||||
update?.subscription.unsubscribe();
|
||||
|
||||
this.updating.delete(variable.state.name);
|
||||
this.variablesToUpdate.delete(variable.state.name);
|
||||
this.updateNextBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO handle this properly (and show error in UI).
|
||||
* Not sure if this should be handled here on in MultiValueVariable
|
||||
*/
|
||||
private handleVariableError(variable: SceneVariable, err: Error) {
|
||||
variable.setState({ loading: false, error: err });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the variable has any dependencies that is currently in variablesToUpdate
|
||||
*/
|
||||
private hasDependendencyInUpdateQueue(variable: SceneVariable) {
|
||||
const dependencies = this.dependencies.get(variable.state.name);
|
||||
|
||||
if (dependencies) {
|
||||
for (const dep of dependencies) {
|
||||
for (const otherVariable of this.variablesToUpdate.values()) {
|
||||
if (otherVariable.state.name === dep) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dependencies from all variables and add those that needs update to the variablesToUpdate map
|
||||
* Then it will start the update process.
|
||||
*/
|
||||
private validateAndUpdateAll() {
|
||||
for (const variable of this.state.variables) {
|
||||
if (variable.validateAndUpdate) {
|
||||
this.variablesToUpdate.set(variable.state.name, variable);
|
||||
}
|
||||
|
||||
if (variable.getDependencies) {
|
||||
this.dependencies.set(variable.state.name, variable.getDependencies());
|
||||
}
|
||||
}
|
||||
|
||||
this.updateNextBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* This will trigger an update of all variables that depend on it.
|
||||
* */
|
||||
private onVariableValueChanged = (event: SceneVariableValueChangedEvent) => {
|
||||
const variable = event.payload;
|
||||
|
||||
// Ignore this change if it is currently updating
|
||||
if (this.updating.has(variable.state.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, deps] of this.dependencies) {
|
||||
if (deps.includes(variable.state.name)) {
|
||||
const otherVariable = this.getByName(name);
|
||||
if (otherVariable) {
|
||||
this.variablesToUpdate.set(name, otherVariable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateNextBatch();
|
||||
};
|
||||
}
|
||||
|
||||
export interface VariableUpdateInProgress {
|
||||
variable: SceneVariable;
|
||||
subscription: Unsubscribable;
|
||||
}
|
@ -1,24 +1,62 @@
|
||||
import { LoadingState } from '@grafana/data';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { BusEventWithPayload } from '@grafana/data';
|
||||
import { VariableHide } from 'app/features/variables/types';
|
||||
|
||||
import { SceneObject, SceneObjectStatePlain } from '../core/types';
|
||||
|
||||
export interface SceneVariableState extends SceneObjectStatePlain {
|
||||
name: string;
|
||||
label?: string;
|
||||
hide?: VariableHide;
|
||||
skipUrlSync?: boolean;
|
||||
state?: LoadingState;
|
||||
loading?: boolean;
|
||||
error?: any | null;
|
||||
description?: string | null;
|
||||
current: { value: string; text?: string };
|
||||
//text: string | string[];
|
||||
//value: string | string[]; // old current.value
|
||||
}
|
||||
|
||||
export interface SceneVariable extends SceneObject<SceneVariableState> {}
|
||||
export interface SceneVariable<TState extends SceneVariableState = SceneVariableState> extends SceneObject<TState> {
|
||||
/**
|
||||
* Should return a string array of other variables this variable is using in it's definition.
|
||||
*/
|
||||
getDependencies?(): string[];
|
||||
|
||||
/**
|
||||
* This function is called on activation or when a dependency changes.
|
||||
*/
|
||||
validateAndUpdate?(): Observable<ValidateAndUpdateResult>;
|
||||
|
||||
/**
|
||||
* Should return the value for the given field path
|
||||
*/
|
||||
getValue(fieldPath?: string): VariableValue;
|
||||
|
||||
/**
|
||||
* Should return the value display text, used by the "text" formatter
|
||||
* Example: ${podId:text}
|
||||
* Useful for variables that have non user friendly values but friendly display text names.
|
||||
*/
|
||||
getValueText?(): string;
|
||||
}
|
||||
|
||||
export type VariableValue = string | string[] | number | number[] | boolean | boolean[] | null | undefined;
|
||||
|
||||
export interface ValidateAndUpdateResult {}
|
||||
export interface VariableValueOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SceneVariableSetState extends SceneObjectStatePlain {
|
||||
variables: SceneVariable[];
|
||||
}
|
||||
|
||||
export interface SceneVariables extends SceneObject<SceneVariableSetState> {
|
||||
getVariableByName(name: string): SceneVariable | undefined;
|
||||
getByName(name: string): SceneVariable | undefined;
|
||||
}
|
||||
|
||||
export class SceneVariableValueChangedEvent extends BusEventWithPayload<SceneVariable> {
|
||||
public static type = 'scene-variable-changed-value';
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { SceneObjectBase } from '../../core/SceneObjectBase';
|
||||
import { SceneVariable, SceneVariableState, VariableValue } from '../types';
|
||||
|
||||
export interface ConstantVariableState extends SceneVariableState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class ConstantVariable
|
||||
extends SceneObjectBase<ConstantVariableState>
|
||||
implements SceneVariable<ConstantVariableState>
|
||||
{
|
||||
public getValue(): VariableValue {
|
||||
return this.state.value;
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
import { lastValueFrom, Observable, of } from 'rxjs';
|
||||
|
||||
import { VariableValueOption } from '../types';
|
||||
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../variants/MultiValueVariable';
|
||||
|
||||
export interface ExampleVariableState extends MultiValueVariableState {
|
||||
optionsToReturn: VariableValueOption[];
|
||||
}
|
||||
|
||||
class ExampleVariable extends MultiValueVariable<ExampleVariableState> {
|
||||
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
|
||||
return of(this.state.optionsToReturn);
|
||||
}
|
||||
}
|
||||
|
||||
describe('MultiValueVariable', () => {
|
||||
describe('When validateAndUpdate is called', () => {
|
||||
it('Should pick first value if current value is not valid', async () => {
|
||||
const variable = new ExampleVariable({
|
||||
name: 'test',
|
||||
options: [],
|
||||
optionsToReturn: [
|
||||
{ label: 'B', value: 'B' },
|
||||
{ label: 'C', value: 'C' },
|
||||
],
|
||||
value: 'A',
|
||||
text: 'A',
|
||||
});
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate());
|
||||
|
||||
expect(variable.state.value).toBe('B');
|
||||
expect(variable.state.text).toBe('B');
|
||||
});
|
||||
|
||||
it('Should keep current value if current value is valid', async () => {
|
||||
const variable = new ExampleVariable({
|
||||
name: 'test',
|
||||
options: [],
|
||||
optionsToReturn: [{ label: 'A', value: 'A' }],
|
||||
value: 'A',
|
||||
text: 'A',
|
||||
});
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate());
|
||||
|
||||
expect(variable.state.value).toBe('A');
|
||||
expect(variable.state.text).toBe('A');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,106 @@
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import { SceneObjectBase } from '../../core/SceneObjectBase';
|
||||
import { SceneObject } from '../../core/types';
|
||||
import {
|
||||
SceneVariable,
|
||||
SceneVariableValueChangedEvent,
|
||||
SceneVariableState,
|
||||
ValidateAndUpdateResult,
|
||||
VariableValue,
|
||||
VariableValueOption,
|
||||
} from '../types';
|
||||
|
||||
export interface MultiValueVariableState extends SceneVariableState {
|
||||
value: string | string[]; // old current.text
|
||||
text: string | string[]; // old current.value
|
||||
options: VariableValueOption[];
|
||||
isMulti?: boolean;
|
||||
}
|
||||
|
||||
export interface VariableGetOptionsArgs {
|
||||
searchFilter?: string;
|
||||
}
|
||||
|
||||
export abstract class MultiValueVariable<TState extends MultiValueVariableState = MultiValueVariableState>
|
||||
extends SceneObjectBase<TState>
|
||||
implements SceneVariable<TState>
|
||||
{
|
||||
/**
|
||||
* The source of value options.
|
||||
*/
|
||||
public abstract getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]>;
|
||||
|
||||
/**
|
||||
* This function is called on when SceneVariableSet is activated or when a dependency changes.
|
||||
*/
|
||||
public validateAndUpdate(): Observable<ValidateAndUpdateResult> {
|
||||
return this.getValueOptions({}).pipe(
|
||||
map((options) => {
|
||||
this.updateValueGivenNewOptions(options);
|
||||
return {};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current value is valid given new options. If not update the value.
|
||||
* TODO: Handle multi valued variables
|
||||
*/
|
||||
private updateValueGivenNewOptions(options: VariableValueOption[]) {
|
||||
if (options.length === 0) {
|
||||
// TODO handle the no value state
|
||||
this.setStateHelper({ value: '?', loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): VariableValue {
|
||||
return this.state.value;
|
||||
}
|
||||
|
||||
public getValueText(): string {
|
||||
if (Array.isArray(this.state.text)) {
|
||||
return this.state.text.join(' + ');
|
||||
}
|
||||
|
||||
return this.state.text;
|
||||
}
|
||||
|
||||
private changeValueAndPublishChangeEvent(value: string | string[], text: string | string[]) {
|
||||
if (value !== this.state.value || text !== this.state.text) {
|
||||
this.setStateHelper({ value, text, loading: false });
|
||||
this.publishEvent(new SceneVariableValueChangedEvent(this), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper function is to counter the contravariance of setState
|
||||
*/
|
||||
private setStateHelper(state: Partial<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!)
|
||||
);
|
||||
};
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree';
|
||||
|
||||
import { SceneComponentProps } from '../../core/types';
|
||||
import { VariableValueSelect } from '../components/VariableValueSelect';
|
||||
import { getVariableDependencies } from '../getVariableDependencies';
|
||||
import { sceneTemplateInterpolator } from '../sceneTemplateInterpolator';
|
||||
import { VariableValueOption } from '../types';
|
||||
|
||||
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
|
||||
|
||||
export interface TestVariableState extends MultiValueVariableState {
|
||||
//query: DataQuery;
|
||||
query: string;
|
||||
delayMs?: number;
|
||||
issuedQuery?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This variable is only designed for unit tests and potentially e2e tests.
|
||||
*/
|
||||
export class TestVariable extends MultiValueVariable<TestVariableState> {
|
||||
private completeUpdate = new Subject<number>();
|
||||
public isGettingValues = true;
|
||||
|
||||
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
|
||||
const { delayMs } = this.state;
|
||||
|
||||
return new Observable<VariableValueOption[]>((observer) => {
|
||||
this.setState({ loading: true });
|
||||
|
||||
this.completeUpdate.subscribe({
|
||||
next: () => {
|
||||
observer.next(this.issueQuery());
|
||||
},
|
||||
});
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
if (delayMs) {
|
||||
timeout = setTimeout(() => this.signalUpdateCompleted(), delayMs);
|
||||
}
|
||||
|
||||
this.isGettingValues = true;
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
this.isGettingValues = false;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private issueQuery() {
|
||||
const interpolatedQuery = sceneTemplateInterpolator(this.state.query, this);
|
||||
const options = queryMetricTree(interpolatedQuery).map((x) => ({ label: x.name, value: x.name }));
|
||||
|
||||
this.setState({
|
||||
issuedQuery: interpolatedQuery,
|
||||
options,
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Useful from tests */
|
||||
public signalUpdateCompleted() {
|
||||
this.completeUpdate.next(1);
|
||||
}
|
||||
|
||||
public getDependencies() {
|
||||
return getVariableDependencies(this.state.query);
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
|
||||
return <VariableValueSelect model={model} />;
|
||||
};
|
||||
}
|
@ -10,7 +10,7 @@ interface Props {
|
||||
text: string;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
disabled: boolean; // todo: optional?
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* htmlFor, needed for the label
|
||||
*/
|
||||
|
@ -8,6 +8,7 @@ import { matchers } from './matchers';
|
||||
failOnConsole({
|
||||
shouldFailOnLog: true,
|
||||
});
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
i18next.use(initReactI18next).init({
|
||||
|
Loading…
Reference in New Issue
Block a user