Scene: Variables and All value support (#59635)

* Working on the all value

* Support for custom allValue

* Fixes

* More progress

* Progress

* Updated

* Fixed issue with multi and All value

* Clarified tests
This commit is contained in:
Torkel Ödegaard 2022-12-13 08:20:09 +01:00 committed by GitHub
parent d2f9d7f39b
commit ef46761b9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 400 additions and 61 deletions

View File

@ -9,7 +9,7 @@ import { getGridWithRowLayoutTest } from './gridWithRow';
import { getNestedScene } from './nested';
import { getQueryVariableDemo } from './queryVariableDemo';
import { getSceneWithRows } from './sceneWithRows';
import { getVariablesDemo } from './variablesDemo';
import { getVariablesDemo, getVariablesDemoWithAll } from './variablesDemo';
interface SceneDef {
title: string;
@ -27,6 +27,7 @@ export function getScenes(): SceneDef[] {
{ title: 'Grid with rows and different queries and time ranges', getScene: getGridWithMultipleTimeRanges },
{ title: 'Multiple grid layouts test', getScene: getMultipleGridLayoutTest },
{ title: 'Variables', getScene: getVariablesDemo },
{ title: 'Variables with All values', getScene: getVariablesDemoWithAll },
{ title: 'Query variable', getScene: getQueryVariableDemo },
];
}

View File

@ -85,3 +85,74 @@ export function getVariablesDemo(standalone: boolean): Scene {
return standalone ? new Scene(state) : new EmbeddedScene(state);
}
export function getVariablesDemoWithAll(): Scene {
const scene = new Scene({
title: 'Variables with All values',
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'server',
query: 'A.*',
value: 'AA',
text: 'AA',
includeAll: true,
defaultToAll: true,
delayMs: 1000,
options: [],
}),
new TestVariable({
name: 'pod',
query: 'A.$server.*',
value: [],
delayMs: 1000,
isMulti: true,
includeAll: true,
defaultToAll: true,
text: '',
options: [],
}),
new TestVariable({
name: 'handler',
query: 'A.$server.$pod.*',
value: [],
delayMs: 1000,
includeAll: true,
defaultToAll: false,
isMulti: true,
text: '',
options: [],
}),
],
}),
layout: new SceneFlexLayout({
direction: 'row',
children: [
new SceneFlexLayout({
children: [
new VizPanel({
pluginId: 'timeseries',
title: 'handler: $handler',
$data: getQueryRunnerWithRandomWalkQuery({
alias: 'handler: $handler',
}),
}),
new SceneCanvasText({
size: { width: '40%' },
text: 'server: ${server} pod:${pod}',
fontSize: 20,
align: 'center',
}),
],
}),
],
}),
$timeRange: new SceneTimeRange(),
actions: [new SceneTimePicker({})],
subMenu: new SceneSubMenu({
children: [new VariableValueSelectors({})],
}),
});
return scene;
}

View File

@ -1,34 +1,13 @@
import { isArray } from 'lodash';
import React from 'react';
import { Select, MultiSelect } from '@grafana/ui';
import { MultiSelect, Select } 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}
closeMenuOnSelect={false}
onChange={(newValue) => {
model.changeValueTo(
newValue.map((v) => v.value!),
newValue.map((v) => v.label!)
);
}}
/>
);
}
const { value, key, loading } = model.useState();
return (
<Select
@ -37,11 +16,47 @@ export function VariableValueSelect({ model }: SceneComponentProps<MultiValueVar
width="auto"
value={value}
allowCustomValue
tabSelectsValue={false}
isLoading={loading}
options={options}
options={model.getOptionsForSelect()}
onChange={(newValue) => {
model.changeValueTo(newValue.value!, newValue.label!);
}}
/>
);
}
export function VariableValueSelectMulti({ model }: SceneComponentProps<MultiValueVariable>) {
const { value, key, loading } = model.useState();
const arrayValue = isArray(value) ? value : [value];
return (
<MultiSelect
id={key}
placeholder="Select value"
width="auto"
value={arrayValue}
tabSelectsValue={false}
allowCustomValue
isLoading={loading}
options={model.getOptionsForSelect()}
closeMenuOnSelect={false}
isClearable={true}
onOpenMenu={() => {}}
onChange={(newValue) => {
model.changeValueTo(
newValue.map((v) => v.value!),
newValue.map((v) => v.label!)
);
}}
/>
);
}
export function renderSelectForVariable(model: MultiValueVariable) {
if (model.state.isMulti) {
return <VariableValueSelectMulti model={model} />;
} else {
return <VariableValueSelect model={model} />;
}
}

View File

@ -326,6 +326,10 @@ function luceneEscape(value: string) {
* unicode handling uses UTF-8 as in ECMA-262.
*/
function encodeURIComponentStrict(str: VariableValueSingle) {
if (typeof str === 'object') {
str = String(str);
}
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => {
return '%' + c.charCodeAt(0).toString(16).toUpperCase();
});

View File

@ -1,3 +1,5 @@
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneObjectStatePlain } from '../../core/types';
import { SceneVariableSet } from '../sets/SceneVariableSet';
@ -45,6 +47,25 @@ describe('sceneInterpolator', () => {
expect(sceneInterpolator(scene.state.nested!, '${atRootOnly}')).toBe('RootValue');
});
describe('Given a variable with allValue', () => {
it('Should not escape it', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'test',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
allValue: '.*',
}),
],
}),
});
expect(sceneInterpolator(scene, '${test:regex}')).toBe('.*');
});
});
describe('Given an expression with fieldPath', () => {
it('Should interpolate correctly', () => {
const scene = new TestScene({

View File

@ -87,6 +87,12 @@ function formatValue(
return '';
}
// Special handling for custom values that should not be formatted / escaped
// This is used by the custom allValue that usually contain wildcards and therefore should not be escaped
if (typeof value === 'object' && 'isCustomValue' in value && formatNameOrFn !== FormatRegistryID.text) {
return value.toString();
}
// if (isAdHoc(variable) && format !== FormatRegistryID.queryParam) {
// return '';
// }

View File

@ -38,12 +38,21 @@ export interface SceneVariable<TState extends SceneVariableState = SceneVariable
export type VariableValue = VariableValueSingle | VariableValueSingle[];
export type VariableValueSingle = string | boolean | number;
export type VariableValueSingle = string | boolean | number | VariableValueCustom;
/**
* This is for edge case values like the custom "allValue" that should not be escaped/formatted like other values.
* The custom all value usually contain wildcards that should not be escaped.
*/
export interface VariableValueCustom {
isCustomValue: true;
toString(): string;
}
export interface ValidateAndUpdateResult {}
export interface VariableValueOption {
label: string;
value: string;
value: VariableValueSingle;
}
export interface SceneVariableSetState extends SceneObjectStatePlain {

View File

@ -1,9 +1,8 @@
import React from 'react';
import { Observable, of } from 'rxjs';
import { SceneComponentProps } from '../../core/types';
import { VariableDependencyConfig } from '../VariableDependencyConfig';
import { VariableValueSelect } from '../components/VariableValueSelect';
import { renderSelectForVariable } from '../components/VariableValueSelect';
import { VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
@ -43,14 +42,9 @@ export class CustomVariable extends MultiValueVariable<CustomVariableState> {
});
return of(options);
// TODO: Support 'All'
//if (this.state.includeAll) {
// options.unshift({ text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false });
//}
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return <VariableValueSelect model={model} />;
return renderSelectForVariable(model);
};
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Observable, of } from 'rxjs';
import { stringToJsRegex, DataSourceInstanceSettings } from '@grafana/data';
@ -7,7 +6,7 @@ import { getDataSourceSrv } from '@grafana/runtime';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps } from '../../core/types';
import { VariableDependencyConfig } from '../VariableDependencyConfig';
import { VariableValueSelect } from '../components/VariableValueSelect';
import { renderSelectForVariable } from '../components/VariableValueSelect';
import { VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
@ -70,11 +69,6 @@ export class DataSourceVariable extends MultiValueVariable<DataSourceVariableSta
options.push({ label: 'No data sources found', value: '' });
}
// TODO: Add support for include All
// if (instanceState.includeAll) {
// options.unshift({ label: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE });
//}
return of(options);
}
@ -83,7 +77,7 @@ export class DataSourceVariable extends MultiValueVariable<DataSourceVariableSta
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return <VariableValueSelect model={model} />;
return renderSelectForVariable(model);
};
}

View File

@ -2,7 +2,7 @@ import { lastValueFrom, Observable, of } from 'rxjs';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { SceneVariableValueChangedEvent, VariableValueOption } from '../types';
import { SceneVariableValueChangedEvent, VariableValueCustom, VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../variants/MultiValueVariable';
export interface ExampleVariableState extends MultiValueVariableState {
@ -46,6 +46,22 @@ describe('MultiValueVariable', () => {
expect(variable.state.text).toBe('B');
});
it('Should pick All value when defaultToAll is true', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
defaultToAll: true,
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe(ALL_VARIABLE_VALUE);
});
it('Should keep current value if current value is valid', async () => {
const variable = new ExampleVariable({
name: 'test',
@ -99,6 +115,26 @@ describe('MultiValueVariable', () => {
expect(variable.state.text).toEqual(['A']);
});
it('Should select All option if none of the current values are valid', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
isMulti: true,
defaultToAll: 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([ALL_VARIABLE_VALUE]);
expect(variable.state.text).toEqual([ALL_VARIABLE_TEXT]);
});
it('Should handle $__all value and send change event even when value is still $__all', async () => {
const variable = new ExampleVariable({
name: 'test',
@ -123,6 +159,60 @@ describe('MultiValueVariable', () => {
});
});
describe('changeValueTo', () => {
it('Should set default empty state to all value if defaultToAll multi', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
isMulti: true,
defaultToAll: true,
optionsToReturn: [],
value: ['1'],
text: ['A'],
});
variable.changeValueTo([]);
expect(variable.state.value).toEqual([ALL_VARIABLE_VALUE]);
});
it('When changing to all value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
isMulti: true,
defaultToAll: true,
optionsToReturn: [],
value: ['1'],
text: ['A'],
});
variable.changeValueTo(['1', ALL_VARIABLE_VALUE]);
// Should clear the value so only all value is set
expect(variable.state.value).toEqual([ALL_VARIABLE_VALUE]);
});
it('When changing from all value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
isMulti: true,
defaultToAll: true,
optionsToReturn: [],
});
variable.changeValueTo([ALL_VARIABLE_VALUE, '1']);
// Should remove the all value so only the new value is present
expect(variable.state.value).toEqual(['1']);
});
});
describe('getValue and getValueText', () => {
it('GetValueText should return text', async () => {
const variable = new ExampleVariable({
@ -163,6 +253,63 @@ describe('MultiValueVariable', () => {
expect(variable.getValue()).toEqual(['1', '2']);
});
it('GetValue should return allValue when value is $__all', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: ALL_VARIABLE_VALUE,
allValue: '.*',
text: 'A',
});
const value = variable.getValue() as VariableValueCustom;
expect(value.isCustomValue).toBe(true);
expect(value.toString()).toBe('.*');
});
});
describe('getOptionsForSelect', () => {
it('Should return options', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [{ label: 'A', value: '1' }],
optionsToReturn: [],
value: '1',
text: 'A',
});
expect(variable.getOptionsForSelect()).toEqual([{ label: 'A', value: '1' }]);
});
it('Should return include All option when includeAll is true', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [{ label: 'A', value: '1' }],
optionsToReturn: [],
includeAll: true,
value: '1',
text: 'A',
});
expect(variable.getOptionsForSelect()).toEqual([
{ label: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE },
{ label: 'A', value: '1' },
]);
});
it('Should add current value if not found', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: '1',
text: 'A',
});
expect(variable.getOptionsForSelect()).toEqual([{ label: 'A', value: '1' }]);
});
});
describe('Url syncing', () => {

View File

@ -12,6 +12,7 @@ import {
ValidateAndUpdateResult,
VariableValue,
VariableValueOption,
VariableValueCustom,
VariableValueSingle,
} from '../types';
@ -20,6 +21,10 @@ export interface MultiValueVariableState extends SceneVariableState {
text: VariableValue; // old current.value
options: VariableValueOption[];
isMulti?: boolean;
includeAll?: boolean;
defaultToAll?: boolean;
allValue?: string;
placeholder?: string;
}
export interface VariableGetOptionsArgs {
@ -71,8 +76,9 @@ export abstract class MultiValueVariable<TState extends MultiValueVariableState
// If no valid values pick the first option
if (validValues.length === 0) {
stateUpdate.value = [options[0].value];
stateUpdate.text = [options[0].label];
const defaultState = this.getDefaultMultiState(options);
stateUpdate.value = defaultState.value;
stateUpdate.text = defaultState.text;
}
// We have valid values, if it's different from current valid values update current values
else if (!isEqual(validValues, this.state.value)) {
@ -84,9 +90,14 @@ export abstract class MultiValueVariable<TState extends MultiValueVariableState
// 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;
if (this.state.defaultToAll) {
stateUpdate.value = ALL_VARIABLE_VALUE;
stateUpdate.text = ALL_VARIABLE_TEXT;
} else {
// Current value is not valid. Set to first of the available options
stateUpdate.value = options[0].value;
stateUpdate.text = options[0].label;
}
}
}
@ -104,6 +115,10 @@ export abstract class MultiValueVariable<TState extends MultiValueVariableState
public getValue(): VariableValue {
if (this.hasAllValue()) {
if (this.state.allValue) {
return new CustomAllValue(this.state.allValue);
}
return this.state.options.map((x) => x.value);
}
@ -127,22 +142,55 @@ export abstract class MultiValueVariable<TState extends MultiValueVariableState
return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE);
}
private getDefaultMultiState(options: VariableValueOption[]) {
if (this.state.defaultToAll) {
return { value: [ALL_VARIABLE_VALUE], text: [ALL_VARIABLE_TEXT] };
} else {
return { value: [options[0].value], text: [options[0].label] };
}
}
/**
* Change the value and publish SceneVariableValueChangedEvent event
*/
public changeValueTo(value: VariableValue, text?: VariableValue) {
if (value !== this.state.value || text !== this.state.text) {
if (!text) {
if (Array.isArray(value)) {
text = value.map((v) => this.findLabelTextForValue(v));
} else {
text = this.findLabelTextForValue(value);
}
// Igore if there is no change
if (value === this.state.value && text === this.state.text) {
return;
}
if (!text) {
if (Array.isArray(value)) {
text = value.map((v) => this.findLabelTextForValue(v));
} else {
text = this.findLabelTextForValue(value);
}
}
if (Array.isArray(value)) {
// If we are a multi valued variable is cleared (empty array) we need to set the default empty state
if (value.length === 0) {
const state = this.getDefaultMultiState(this.state.options);
value = state.value;
text = state.text;
}
this.setStateHelper({ value, text, loading: false });
this.publishEvent(new SceneVariableValueChangedEvent(this), true);
// If last value is the All value then replace all with it
if (value[value.length - 1] === ALL_VARIABLE_VALUE) {
value = [ALL_VARIABLE_VALUE];
text = [ALL_VARIABLE_TEXT];
}
// If the first value is the ALL value and we have other values, then remove the All value
else if (value[0] === ALL_VARIABLE_VALUE && value.length > 1) {
value.shift();
if (Array.isArray(text)) {
text.shift();
}
}
}
this.setStateHelper({ value, text, loading: false });
this.publishEvent(new SceneVariableValueChangedEvent(this), true);
}
private findLabelTextForValue(value: VariableValueSingle): VariableValueSingle {
@ -166,6 +214,36 @@ export abstract class MultiValueVariable<TState extends MultiValueVariableState
const test: SceneObject<MultiValueVariableState> = this;
test.setState(state);
}
public getOptionsForSelect(): VariableValueOption[] {
let options = this.state.options;
if (this.state.includeAll) {
options = [{ value: ALL_VARIABLE_VALUE, label: ALL_VARIABLE_TEXT }, ...options];
}
if (!Array.isArray(this.state.value)) {
const current = options.find((x) => x.value === this.state.value);
if (!current) {
options = [{ value: this.state.value, label: String(this.state.text) }, ...options];
}
}
return options;
}
}
/**
* The custom allValue needs a special wrapping / handling to make it not be formatted / escaped like normal values
*/
class CustomAllValue implements VariableValueCustom {
public isCustomValue: true = true;
public constructor(private _value: string) {}
public toString() {
return this._value;
}
}
export class MultiValueUrlSyncHandler<TState extends MultiValueVariableState = MultiValueVariableState>

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Observable, Subject } from 'rxjs';
import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree';
@ -6,7 +5,7 @@ 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 { renderSelectForVariable } from '../components/VariableValueSelect';
import { VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
@ -89,6 +88,6 @@ export class TestVariable extends MultiValueVariable<TestVariableState> {
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return <VariableValueSelect model={model} />;
return renderSelectForVariable(model);
};
}