Scene: Variables and support for declaring variable dependencies and getting notified or re-rendered when they change (#58299)

* 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

* Merge fixes

* Some review fixes

* Updated comment

* Added forceRender function

* Add back fail on console log
This commit is contained in:
Torkel Ödegaard 2022-11-15 12:54:24 +01:00 committed by GitHub
parent 93b4b9154e
commit 84a69135a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 539 additions and 147 deletions

View File

@ -4570,10 +4570,7 @@ exports[`better eslint`] = {
[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.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/scenes/core/SceneTimeRange.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
@ -4582,6 +4579,11 @@ exports[`better eslint`] = {
"public/app/features/scenes/core/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/scenes/core/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/features/scenes/editor/SceneObjectTree.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
@ -4593,6 +4595,12 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"]
],
"public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/features/scenes/variables/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -49,9 +49,9 @@ type MockQuotaChecker_CheckQuotaReached_Call struct {
}
// CheckQuotaReached is a helper method to define mock.On call
// - ctx context.Context
// - target quota.TargetSrv
// - scopeParams *quota.ScopeParameters
// - ctx context.Context
// - target quota.TargetSrv
// - scopeParams *quota.ScopeParameters
func (_e *MockQuotaChecker_Expecter) CheckQuotaReached(ctx interface{}, target interface{}, scopeParams interface{}) *MockQuotaChecker_CheckQuotaReached_Call {
return &MockQuotaChecker_CheckQuotaReached_Call{Call: _e.mock.On("CheckQuotaReached", ctx, target, scopeParams)}
}

View File

@ -4,7 +4,7 @@ import { Field, Input } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneLayoutChildState } from '../core/types';
import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator';
import { VariableDependencyConfig } from '../variables/VariableDependencyConfig';
export interface SceneCanvasTextState extends SceneLayoutChildState {
text: string;
@ -15,9 +15,10 @@ export interface SceneCanvasTextState extends SceneLayoutChildState {
export class SceneCanvasText extends SceneObjectBase<SceneCanvasTextState> {
public static Editor = Editor;
protected _variableDependency = new VariableDependencyConfig(this, { statePaths: ['text'] });
public static Component = ({ model }: SceneComponentProps<SceneCanvasText>) => {
const { text, fontSize = 20, align = 'left' } = model.useState();
const textInterpolated = sceneTemplateInterpolator(text, model);
const { text, fontSize = 20, align = 'left', key } = model.useState();
const style: CSSProperties = {
fontSize: fontSize,
@ -28,7 +29,11 @@ export class SceneCanvasText extends SceneObjectBase<SceneCanvasTextState> {
justifyContent: align,
};
return <div style={style}>{textInterpolated}</div>;
return (
<div style={style} data-testid={key}>
{model.interpolate(text)}
</div>
);
};
}

View File

@ -7,6 +7,7 @@ import { Field, PanelChrome, Input } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneLayoutChildState } from '../core/types';
import { VariableDependencyConfig } from '../variables/VariableDependencyConfig';
import { SceneDragHandle } from './SceneDragHandle';
@ -21,6 +22,10 @@ export class VizPanel extends SceneObjectBase<VizPanelState> {
public static Component = ScenePanelRenderer;
public static Editor = VizPanelEditor;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['title'],
});
public onSetTimeRange = (timeRange: AbsoluteTimeRange) => {
const sceneTimeRange = this.getTimeRange();
sceneTimeRange.setState({
@ -41,6 +46,8 @@ function ScenePanelRenderer({ model }: SceneComponentProps<VizPanel>) {
const isDraggable = layout.state.isDraggable ? state.isDraggable : false;
const dragHandle = <SceneDragHandle layoutKey={layout.state.key!} />;
const titleInterpolated = model.interpolate(title);
return (
<AutoSizer>
{({ width, height }) => {
@ -49,7 +56,12 @@ function ScenePanelRenderer({ model }: SceneComponentProps<VizPanel>) {
}
return (
<PanelChrome title={title} width={width} height={height} leftItems={isDraggable ? [dragHandle] : undefined}>
<PanelChrome
title={titleInterpolated}
width={width}
height={height}
leftItems={isDraggable ? [dragHandle] : undefined}
>
{(innerWidth, innerHeight) => (
<>
<PanelRenderer

View File

@ -24,6 +24,10 @@ export function SceneComponentWrapper<T extends SceneObject>({
};
}, [model]);
/** Useful for tests and evaluating efficiency in reducing renderings */
// @ts-ignore
model._renderCount += 1;
if (!isEditing) {
return inner;
}

View File

@ -5,6 +5,9 @@ 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 { SceneComponentWrapper } from './SceneComponentWrapper';
import { SceneObjectStateChangedEvent } from './events';
import {
@ -16,6 +19,7 @@ import {
SceneObjectState,
SceneLayoutState,
} from './types';
import { cloneSceneObject, forEachSceneObjectInState } from './utils';
export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObjectState>
implements SceneObject<TState>
@ -25,9 +29,13 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
private _state: TState;
private _events = new EventBusSrv();
/** Incremented in SceneComponentWrapper, useful for tests and rendering optimizations */
protected _renderCount = 0;
protected _parent?: SceneObject;
protected _subs = new Subscription();
protected _variableDependency: SceneVariableDependencyConfigLike | undefined;
public constructor(state: TState) {
if (!state.key) {
state.key = uuidv4();
@ -53,6 +61,11 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
return this._parent;
}
/** Returns variable dependency config */
public get variableDependency(): SceneVariableDependencyConfigLike | undefined {
return this._variableDependency;
}
/**
* Used in render functions when rendering a SceneObject.
* Wraps the component in an EditWrapper that handles edit mode
@ -69,19 +82,7 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
}
private setParent() {
for (const propValue of Object.values(this._state)) {
if (propValue instanceof SceneObjectBase) {
propValue._parent = this;
}
if (Array.isArray(propValue)) {
for (const child of propValue) {
if (child instanceof SceneObjectBase) {
child._parent = this;
}
}
}
}
forEachSceneObjectInState(this._state, (child) => (child._parent = this));
}
/**
@ -104,6 +105,7 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
...this._state,
...update,
};
this.setParent();
this._subject.next(this._state);
@ -118,7 +120,6 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
true
);
}
/*
* Publish an event and optionally bubble it up the scene
**/
@ -216,6 +217,18 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
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
*/
@ -247,36 +260,29 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
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({});
}
/**
* Will create new SceneItem with shalled cloned state, but all states items of type SceneObject are deep cloned
* Will create new SceneObject with shallow-cloned state, but all state items of type SceneObject are deep cloned
*/
public clone(withState?: Partial<TState>): this {
const clonedState = { ...this.state };
return cloneSceneObject(this, withState);
}
// Clone any SceneItems in state
for (const key in clonedState) {
const propValue = clonedState[key];
if (propValue instanceof SceneObjectBase) {
clonedState[key] = propValue.clone();
}
// Clone scene objects in arrays
if (Array.isArray(propValue)) {
const newArray: any = [];
for (const child of propValue) {
if (child instanceof SceneObjectBase) {
newArray.push(child.clone());
} else {
newArray.push(child);
}
}
clonedState[key] = newArray;
}
/**
* 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;
}
Object.assign(clonedState, withState);
return new (this.constructor as any)(clonedState);
return sceneTemplateInterpolator(value, this);
}
}

View File

@ -3,7 +3,7 @@ import { Observer, Subscription, Unsubscribable } from 'rxjs';
import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, UrlQueryMap } from '@grafana/data';
import { SceneVariables } from '../variables/types';
import { SceneVariableDependencyConfigLike, SceneVariables } from '../variables/types';
export interface SceneObjectStatePlain {
key?: string;
@ -62,6 +62,9 @@ export interface SceneObject<TState extends SceneObjectState = SceneObjectState>
/** SceneObject parent */
readonly parent?: SceneObject;
/** This abtractions declares what variables the scene object depends on and how to handle when they change value. **/
readonly variableDependency?: SceneVariableDependencyConfigLike;
/** Subscribe to state changes */
subscribeToState(observer?: Partial<Observer<TState>>): Subscription;
@ -92,6 +95,9 @@ export interface SceneObject<TState extends SceneObjectState = SceneObjectState>
/** 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;
@ -106,6 +112,9 @@ export interface SceneObject<TState extends SceneObjectState = SceneObjectState>
/** To be replaced by declarative method */
Editor(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null;
/** Force a re-render, should only be needed when variable values change */
forceRender(): void;
}
export type SceneLayoutChild = SceneObject<SceneLayoutChildState | SceneLayoutState>;

View File

@ -0,0 +1,56 @@
import { SceneObjectBase } from './SceneObjectBase';
import { SceneObjectState, SceneObjectStatePlain } from './types';
/**
* Will call callback for all first level child scene objects and scene objects inside arrays
*/
export function forEachSceneObjectInState(state: SceneObjectStatePlain, callback: (scene: SceneObjectBase) => void) {
for (const propValue of Object.values(state)) {
if (propValue instanceof SceneObjectBase) {
callback(propValue);
}
if (Array.isArray(propValue)) {
for (const child of propValue) {
if (child instanceof SceneObjectBase) {
callback(child);
}
}
}
}
}
/**
* Will create new SceneItem with shalled cloned state, but all states items of type SceneObject are deep cloned
*/
export function cloneSceneObject<T extends SceneObjectBase<TState>, TState extends SceneObjectState>(
sceneObject: SceneObjectBase<TState>,
withState?: Partial<TState>
): T {
const clonedState = { ...sceneObject.state };
// Clone any SceneItems in state
for (const key in clonedState) {
const propValue = clonedState[key];
if (propValue instanceof SceneObjectBase) {
clonedState[key] = propValue.clone();
}
// Clone scene objects in arrays
if (Array.isArray(propValue)) {
const newArray: any = [];
for (const child of propValue) {
if (child instanceof SceneObjectBase) {
newArray.push(child.clone());
} else {
newArray.push(child);
}
}
clonedState[key] = newArray;
}
}
Object.assign(clonedState, withState);
return new (sceneObject.constructor as any)(clonedState);
}

View File

@ -18,6 +18,7 @@ import { runRequest } from 'app/features/query/state/runRequest';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObjectStatePlain } from '../core/types';
import { VariableDependencyConfig } from '../variables/VariableDependencyConfig';
export interface QueryRunnerState extends SceneObjectStatePlain {
data?: PanelData;
@ -31,6 +32,11 @@ export interface DataQueryExtended extends DataQuery {
export class SceneQueryRunner extends SceneObjectBase<QueryRunnerState> {
private querySub?: Unsubscribable;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['queries'],
onReferencedVariableValueChanged: () => this.runQueries(),
});
public activate() {
super.activate();

View File

@ -9,7 +9,8 @@ import { VizPanel } from '../components/VizPanel';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getFlexLayoutTest(): Scene {
const scene = new Scene({
@ -51,18 +52,7 @@ export function getFlexLayoutTest(): Scene {
}),
$editor: new SceneEditManager({}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
$data: new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: {
uid: 'gdev-testdata',
type: 'testdata',
},
scenarioId: 'random_walk',
},
],
}),
$data: getQueryRunnerWithRandomWalkQuery(),
actions: [new SceneTimePicker({})],
});
@ -70,19 +60,10 @@ export function getFlexLayoutTest(): Scene {
}
export function getScenePanelRepeaterTest(): Scene {
const queryRunner = new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: {
uid: 'gdev-testdata',
type: 'testdata',
},
seriesCount: 2,
alias: '__server_names',
scenarioId: 'random_walk',
},
],
const queryRunner = getQueryRunnerWithRandomWalkQuery({
seriesCount: 2,
alias: '__server_names',
scenarioId: 'random_walk',
});
const scene = new Scene({

View File

@ -1,6 +1,8 @@
import { TestDataQuery } from 'app/plugins/datasource/testdata/types';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
export function getQueryRunnerWithRandomWalkQuery() {
export function getQueryRunnerWithRandomWalkQuery(overrides?: Partial<TestDataQuery>) {
return new SceneQueryRunner({
queries: [
{
@ -10,6 +12,7 @@ export function getQueryRunnerWithRandomWalkQuery() {
type: 'testdata',
},
scenarioId: 'random_walk',
...overrides,
},
],
});

View File

@ -4,25 +4,18 @@ import { Scene } from '../components/Scene';
import { SceneCanvasText } from '../components/SceneCanvasText';
import { SceneSubMenu } from '../components/SceneSubMenu';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { VizPanel } from '../components/VizPanel';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { VariableValueSelectors } from '../variables/components/VariableValueSelectors';
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { TestVariable } from '../variables/variants/TestVariable';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
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({
@ -46,12 +39,34 @@ export function getVariablesDemo(): Scene {
query: 'A.$server.$pod.*',
value: 'handler',
delayMs: 1000,
isMulti: true,
//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 - pod: ${server} - ${pod}',
fontSize: 20,
align: 'center',
}),
],
}),
],
}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
actions: [new SceneTimePicker({})],
subMenu: new SceneSubMenu({

View File

@ -0,0 +1,86 @@
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObjectStatePlain } from '../core/types';
import { VariableDependencyConfig } from './VariableDependencyConfig';
import { ConstantVariable } from './variants/ConstantVariable';
interface TestState extends SceneObjectStatePlain {
query: string;
otherProp: string;
nested: {
query: string;
};
}
class TestObj extends SceneObjectBase<TestState> {
public constructor() {
super({
query: 'query with ${queryVarA} ${queryVarB}',
otherProp: 'string with ${otherPropA}',
nested: {
query: 'nested object with ${nestedVarA}',
},
});
}
}
describe('VariableDependencySet', () => {
it('Should be able to extract dependencies from all state', () => {
const sceneObj = new TestObj();
const deps = new VariableDependencyConfig(sceneObj, {});
expect(deps.getNames()).toEqual(new Set(['queryVarA', 'queryVarB', 'nestedVarA', 'otherPropA']));
});
it('Should be able to extract dependencies from statePaths', () => {
const sceneObj = new TestObj();
const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] });
expect(deps.getNames()).toEqual(new Set(['queryVarA', 'queryVarB', 'nestedVarA']));
expect(deps.hasDependencyOn('queryVarA')).toBe(true);
});
it('Should cache variable extraction', () => {
const sceneObj = new TestObj();
const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] });
deps.getNames();
deps.getNames();
expect(deps.scanCount).toBe(1);
});
it('Should not rescan if state changes but not any of the state paths to scan', () => {
const sceneObj = new TestObj();
const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] });
deps.getNames();
sceneObj.setState({ otherProp: 'new value' });
deps.getNames();
expect(deps.scanCount).toBe(1);
});
it('Should re-scan when both state and specific state path change', () => {
const sceneObj = new TestObj();
const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] });
deps.getNames();
sceneObj.setState({ query: 'new query with ${newVar}' });
expect(deps.getNames()).toEqual(new Set(['newVar', 'nestedVarA']));
expect(deps.scanCount).toBe(2);
});
it('variableValuesChanged should only call onReferencedVariableValueChanged if dependent variable has changed', () => {
const sceneObj = new TestObj();
const fn = jest.fn();
const deps = new VariableDependencyConfig(sceneObj, { onReferencedVariableValueChanged: fn });
deps.variableValuesChanged(new Set([new ConstantVariable({ name: 'not-dep', value: '1' })]));
expect(fn.mock.calls.length).toBe(0);
deps.variableValuesChanged(new Set([new ConstantVariable({ name: 'queryVarA', value: '1' })]));
expect(fn.mock.calls.length).toBe(1);
});
});

View File

@ -0,0 +1,130 @@
import { variableRegex } from 'app/features/variables/utils';
import { SceneObject, SceneObjectState } from '../core/types';
import { SceneVariable, SceneVariableDependencyConfigLike } from './types';
interface VariableDependencyConfigOptions<TState extends SceneObjectState> {
/**
* State paths to scan / extract variable dependencies from. Leave empty to scan all paths.
*/
statePaths?: Array<keyof TState>;
/**
* Optional way to customize how to handle when a dependent variable changes
* If not specified the default behavior is to trigger a re-render
*/
onReferencedVariableValueChanged?: () => void;
}
export class VariableDependencyConfig<TState extends SceneObjectState> implements SceneVariableDependencyConfigLike {
private _state: TState | undefined;
private _dependencies = new Set<string>();
private _statePaths?: Array<keyof TState>;
private _onReferencedVariableValueChanged: () => void;
public scanCount = 0;
public constructor(private _sceneObject: SceneObject<TState>, options: VariableDependencyConfigOptions<TState>) {
this._statePaths = options.statePaths;
this._onReferencedVariableValueChanged =
options.onReferencedVariableValueChanged ?? this.defaultHandlerReferencedVariableValueChanged;
}
/**
* Used to check for dependency on a specific variable
*/
public hasDependencyOn(name: string): boolean {
return this.getNames().has(name);
}
/**
* This is called whenever any set of variables have new values. It up to this implementation to check if it's relevant given the current dependencies.
*/
public variableValuesChanged(variables: Set<SceneVariable>) {
const deps = this.getNames();
for (const variable of variables) {
if (deps.has(variable.state.name)) {
this._onReferencedVariableValueChanged();
return;
}
}
}
/**
* Only way to force a re-render is to update state right now
*/
private defaultHandlerReferencedVariableValueChanged = () => {
this._sceneObject.forceRender();
};
public getNames(): Set<string> {
const prevState = this._state;
const newState = (this._state = this._sceneObject.state);
if (!prevState) {
// First time we always scan for dependencies
this.scanStateForDependencies(this._state);
return this._dependencies;
}
// Second time we only scan if state is a different and if any specific state path has changed
if (newState !== prevState) {
if (this._statePaths) {
for (const path of this._statePaths) {
if (newState[path] !== prevState[path]) {
this.scanStateForDependencies(newState);
break;
}
}
} else {
this.scanStateForDependencies(newState);
}
}
return this._dependencies;
}
private scanStateForDependencies(state: TState) {
this._dependencies.clear();
this.scanCount += 1;
if (this._statePaths) {
for (const path of this._statePaths) {
const value = state[path];
if (value) {
this.extractVariablesFrom(value);
}
}
} else {
this.extractVariablesFrom(state);
}
}
private extractVariablesFrom(value: unknown) {
variableRegex.lastIndex = 0;
const stringToCheck = typeof value !== 'string' ? safeStringifyValue(value) : value;
const matches = stringToCheck.matchAll(variableRegex);
if (!matches) {
return;
}
for (const match of matches) {
const [, var1, var2, , var3] = match;
const variableName = var1 || var2 || var3;
this._dependencies.add(variableName);
}
}
}
const safeStringifyValue = (value: unknown) => {
try {
return JSON.stringify(value, null);
} catch (error) {
console.error(error);
}
return '';
};

View File

@ -6,14 +6,14 @@ import { Tooltip } from '@grafana/ui';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneComponentProps, SceneObject, SceneObjectStatePlain } from '../../core/types';
import { SceneVariables, SceneVariableState } from '../types';
import { SceneVariableState } from '../types';
export class VariableValueSelectors extends SceneObjectBase<SceneObjectStatePlain> {
public static Component = VariableValueSelectorsRenderer;
}
function VariableValueSelectorsRenderer({ model }: SceneComponentProps<VariableValueSelectors>) {
const variables = getVariables(model).useState();
const variables = model.getVariables()!.useState();
return (
<>
@ -24,18 +24,6 @@ function VariableValueSelectorsRenderer({ model }: SceneComponentProps<VariableV
);
}
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();

View File

@ -7,6 +7,11 @@ 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) => {

View File

@ -1,3 +1,9 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { SceneCanvasText } from '../../components/SceneCanvasText';
import { SceneFlexLayout } from '../../components/layout/SceneFlexLayout';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneObjectStatePlain } from '../../core/types';
import { TestVariable } from '../variants/TestVariable';
@ -91,5 +97,41 @@ describe('SceneVariableList', () => {
scene.deactivate();
expect(A.isGettingValues).toBe(false);
});
describe('When update process completed and variables have changed values', () => {
it('Should trigger re-renders of dependent scene objects', 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 helloText = new SceneCanvasText({ text: 'Hello' });
const sceneObjectWithVariable = new SceneCanvasText({ text: '$A - $B' });
const scene = new SceneFlexLayout({
$variables: new SceneVariableSet({ variables: [B, A] }),
children: [helloText, sceneObjectWithVariable],
});
render(<scene.Component model={scene} />);
expect(screen.getByText('Hello')).toBeInTheDocument();
act(() => {
A.signalUpdateCompleted();
B.signalUpdateCompleted();
});
expect(screen.getByText('AA - AAA')).toBeInTheDocument();
expect((helloText as any)._renderCount).toBe(1);
expect((sceneObjectWithVariable as any)._renderCount).toBe(2);
act(() => {
B.onSingleValueChange({ value: 'B', text: 'B' });
});
expect(screen.getByText('AA - B')).toBeInTheDocument();
expect((helloText as any)._renderCount).toBe(1);
expect((sceneObjectWithVariable as any)._renderCount).toBe(3);
});
});
});
});

View File

@ -1,20 +1,19 @@
import { Unsubscribable } from 'rxjs';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneObject } from '../../core/types';
import { forEachSceneObjectInState } from '../../core/utils';
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>();
private variablesThatHaveChanged = new Set<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[]>();
private variablesToUpdate = new Set<SceneVariable>();
/** Variables currently updating */
private updating = new Map<string, VariableUpdateInProgress>();
private updating = new Map<SceneVariable, VariableUpdateInProgress>();
public getByName(name: string): SceneVariable | undefined {
// TODO: Replace with index
@ -50,7 +49,13 @@ export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> imp
* 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 we have nothing more to update and variable values changed we need to update scene objects that depend on these variables
if (this.variablesToUpdate.size === 0 && this.variablesThatHaveChanged.size > 0) {
this.notifyDependentSceneObjects();
return;
}
for (const variable of this.variablesToUpdate) {
if (!variable.validateAndUpdate) {
throw new Error('Variable added to variablesToUpdate but does not have validateAndUpdate');
}
@ -60,7 +65,7 @@ export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> imp
continue;
}
this.updating.set(name, {
this.updating.set(variable, {
variable,
subscription: variable.validateAndUpdate().subscribe({
next: () => this.validateAndUpdateCompleted(variable),
@ -74,11 +79,11 @@ export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> imp
* 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);
const update = this.updating.get(variable);
update?.subscription.unsubscribe();
this.updating.delete(variable.state.name);
this.variablesToUpdate.delete(variable.state.name);
this.updating.delete(variable);
this.variablesToUpdate.delete(variable);
this.updateNextBatch();
}
@ -94,15 +99,13 @@ export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> imp
* 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 (!variable.variableDependency) {
return false;
}
if (dependencies) {
for (const dep of dependencies) {
for (const otherVariable of this.variablesToUpdate.values()) {
if (otherVariable.state.name === dep) {
return true;
}
}
for (const otherVariable of this.variablesToUpdate.values()) {
if (variable.variableDependency?.hasDependencyOn(otherVariable.state.name)) {
return true;
}
}
@ -116,11 +119,7 @@ export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> imp
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.variablesToUpdate.add(variable);
}
}
@ -131,24 +130,54 @@ export class SceneVariableSet extends SceneObjectBase<SceneVariableSetState> imp
* This will trigger an update of all variables that depend on it.
* */
private onVariableValueChanged = (event: SceneVariableValueChangedEvent) => {
const variable = event.payload;
const variableThatChanged = event.payload;
this.variablesThatHaveChanged.add(variableThatChanged);
// Ignore this change if it is currently updating
if (this.updating.has(variable.state.name)) {
if (this.updating.has(variableThatChanged)) {
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);
// Add variables that depend on the changed variable to the update queue
for (const otherVariable of this.state.variables) {
if (otherVariable.variableDependency) {
if (otherVariable.variableDependency.hasDependencyOn(variableThatChanged.state.name)) {
this.variablesToUpdate.add(otherVariable);
}
}
}
this.updateNextBatch();
};
/**
* Walk scene object graph and update all objects that depend on variables that have changed
*/
private notifyDependentSceneObjects() {
if (!this.parent) {
return;
}
this.traverseSceneAndNotify(this.parent);
this.variablesThatHaveChanged.clear();
}
/**
* Recursivly walk the full scene object graph and notify all objects with dependencies that include any of changed variables
*/
private traverseSceneAndNotify(sceneObject: SceneObject) {
// No need to notify variables under this SceneVariableSet
if (this === sceneObject) {
return;
}
if (sceneObject.variableDependency) {
sceneObject.variableDependency.variableValuesChanged(this.variablesThatHaveChanged);
}
forEachSceneObjectInState(sceneObject.state, (child) => this.traverseSceneAndNotify(child));
}
}
export interface VariableUpdateInProgress {

View File

@ -13,16 +13,9 @@ export interface SceneVariableState extends SceneObjectStatePlain {
loading?: boolean;
error?: any | null;
description?: string | null;
//text: string | string[];
//value: string | string[]; // old current.value
}
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.
*/
@ -60,3 +53,17 @@ export interface SceneVariables extends SceneObject<SceneVariableSetState> {
export class SceneVariableValueChangedEvent extends BusEventWithPayload<SceneVariable> {
public static type = 'scene-variable-changed-value';
}
export interface SceneVariableDependencyConfigLike {
/** Return all variable names this object depend on */
getNames(): Set<string>;
/** Used to check for dependency on a specific variable */
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.
**/
variableValuesChanged(variables: Set<SceneVariable>): void;
}

View File

@ -4,15 +4,14 @@ import { Observable, Subject } from 'rxjs';
import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree';
import { SceneComponentProps } from '../../core/types';
import { VariableDependencyConfig } from '../VariableDependencyConfig';
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;
@ -25,6 +24,10 @@ export class TestVariable extends MultiValueVariable<TestVariableState> {
private completeUpdate = new Subject<number>();
public isGettingValues = true;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['query'],
});
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
const { delayMs } = this.state;
@ -69,10 +72,6 @@ export class TestVariable extends MultiValueVariable<TestVariableState> {
this.completeUpdate.next(1);
}
public getDependencies() {
return getVariableDependencies(this.state.query);
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return <VariableValueSelect model={model} />;
};

View File

@ -24,6 +24,7 @@ export interface TestDataQuery extends DataQuery {
csvFileName?: string;
csvContent?: string;
rawFrameContent?: string;
seriesCount?: number;
usa?: USAQuery;
errorType?: 'server_panic' | 'frontend_exception' | 'frontend_observable';
}