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:
Torkel Ödegaard 2022-11-09 08:02:24 +01:00 committed by GitHub
parent c646ff0ce3
commit 6ed35292fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 829 additions and 35 deletions

View File

@ -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,10 +56,13 @@ function SceneRenderer({ model }: SceneComponentProps<Scene>) {
return (
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Canvas} toolbar={pageToolbar}>
<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>
);
}

View File

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

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

View File

@ -1,4 +1,4 @@
import { SceneVariableSet } from '../variables/SceneVariableSet';
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { SceneDataNode } from './SceneDataNode';
import { SceneObjectBase } from './SceneObjectBase';

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ interface Props {
text: string;
loading: boolean;
onCancel: () => void;
disabled: boolean; // todo: optional?
disabled?: boolean;
/**
* htmlFor, needed for the label
*/

View File

@ -8,6 +8,7 @@ import { matchers } from './matchers';
failOnConsole({
shouldFailOnLog: true,
});
expect.extend(matchers);
i18next.use(initReactI18next).init({