Skip to content

Commit 934ef8a

Browse files
authored
Behaviors: Add state and runtime behavior to any scene object (#119)
1 parent be489e1 commit 934ef8a

12 files changed

+328
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react';
2+
3+
import { SelectableValue } from '@grafana/data';
4+
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
5+
import { RadioButtonGroup } from '@grafana/ui';
6+
7+
export interface SceneRadioToggleState extends SceneObjectState {
8+
options: Array<SelectableValue<string>>;
9+
value: string;
10+
}
11+
12+
export class SceneRadioToggle extends SceneObjectBase<SceneRadioToggleState> {
13+
public onChange = (value: string) => {
14+
this.setState({ value });
15+
};
16+
17+
public static Component = ({ model }: SceneComponentProps<SceneRadioToggle>) => {
18+
const { options, value } = model.useState();
19+
20+
return <RadioButtonGroup options={options} value={value} onChange={model.onChange} />;
21+
};
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { rangeUtil } from '@grafana/data';
2+
import { sceneGraph, SceneObjectState, SceneTimeRangeState } from '@grafana/scenes';
3+
import { HiddenLayoutItemBehavior } from './HiddenLayoutItemBehavior';
4+
5+
export interface HiddenForTimeRangeBehaviorState extends SceneObjectState {
6+
greaterThan: string;
7+
}
8+
9+
/**
10+
* Just a proof of concept example of a behavior
11+
*/
12+
export class HiddenForTimeRangeBehavior extends HiddenLayoutItemBehavior<HiddenForTimeRangeBehaviorState> {
13+
public constructor(state: HiddenForTimeRangeBehaviorState) {
14+
super(state);
15+
16+
this.addActivationHandler(() => {
17+
this._subs.add(sceneGraph.getTimeRange(this).subscribeToState(this._onTimeRangeChange));
18+
this._onTimeRangeChange(sceneGraph.getTimeRange(this).state);
19+
});
20+
}
21+
22+
private _onTimeRangeChange = (state: SceneTimeRangeState) => {
23+
const range = rangeUtil.convertRawToRange({ from: this.state.greaterThan, to: 'now' });
24+
25+
if (state.value.from.valueOf() < range.from.valueOf()) {
26+
this.setHidden();
27+
} else {
28+
this.setVisible();
29+
}
30+
};
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { SceneFlexItem, SceneFlexLayout, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
2+
3+
/**
4+
* Just a proof of concept example of a behavior
5+
*/
6+
export class HiddenLayoutItemBehavior<
7+
TState extends SceneObjectState = SceneObjectState
8+
> extends SceneObjectBase<TState> {
9+
public constructor(state: TState) {
10+
super(state);
11+
}
12+
13+
protected setHidden() {
14+
const parentLayoutItem = getClosestLayoutItem(this);
15+
16+
if (!parentLayoutItem.state.isHidden) {
17+
parentLayoutItem.setState({ isHidden: true });
18+
}
19+
}
20+
21+
protected setVisible() {
22+
const parentLayoutItem = getClosestLayoutItem(this);
23+
24+
if (parentLayoutItem.state.isHidden) {
25+
parentLayoutItem.setState({ isHidden: false });
26+
}
27+
}
28+
}
29+
30+
function getClosestLayoutItem(obj: SceneObject): SceneFlexItem | SceneFlexLayout {
31+
if (obj instanceof SceneFlexItem || obj instanceof SceneFlexLayout) {
32+
return obj;
33+
}
34+
35+
if (!obj.parent) {
36+
throw new Error('Could not find parent flex item');
37+
}
38+
39+
return getClosestLayoutItem(obj.parent);
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { SceneDataState, sceneGraph } from '@grafana/scenes';
2+
import { HiddenLayoutItemBehavior } from './HiddenLayoutItemBehavior';
3+
4+
export class HiddenWhenNoDataBehavior extends HiddenLayoutItemBehavior {
5+
public constructor() {
6+
super({});
7+
8+
this.addActivationHandler(() => {
9+
this._subs.add(sceneGraph.getData(this).subscribeToState(this._onData));
10+
});
11+
}
12+
13+
private _onData = (data: SceneDataState) => {
14+
if (!data.data) {
15+
return;
16+
}
17+
18+
if (data.data && data.data.series.length === 0) {
19+
this.setHidden();
20+
return;
21+
}
22+
23+
this.setVisible();
24+
};
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { sceneGraph, SceneObject, SceneObjectState } from '@grafana/scenes';
2+
import { HiddenLayoutItemBehavior } from './HiddenLayoutItemBehavior';
3+
4+
export interface ShowBasedOnConditionBehaviorState extends SceneObjectState {
5+
references: string[];
6+
condition: (...args: any[]) => boolean;
7+
}
8+
9+
export interface ShowBasedCondition {
10+
references: SceneObject[];
11+
condition: () => boolean;
12+
}
13+
14+
export class ShowBasedOnConditionBehavior extends HiddenLayoutItemBehavior<ShowBasedOnConditionBehaviorState> {
15+
private _resolvedRefs: SceneObject[] = [];
16+
17+
public constructor(state: ShowBasedOnConditionBehaviorState) {
18+
super(state);
19+
20+
this.addActivationHandler(() => this._onActivate());
21+
}
22+
23+
private _onActivate() {
24+
// Subscribe to references
25+
for (const objectKey of this.state.references) {
26+
const solvedRef = sceneGraph.findObject(this, (obj) => obj.state.key === objectKey);
27+
if (!solvedRef) {
28+
throw new Error(`SceneObject with key ${objectKey} not found in scene graph`);
29+
}
30+
31+
this._resolvedRefs.push(solvedRef);
32+
this._subs.add(solvedRef.subscribeToState(() => this._onReferenceChanged()));
33+
}
34+
35+
this._onReferenceChanged();
36+
}
37+
38+
private _onReferenceChanged() {
39+
if (this.state.condition(...this._resolvedRefs)) {
40+
this.setVisible();
41+
} else {
42+
this.setHidden();
43+
}
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
EmbeddedScene,
3+
SceneAppPage,
4+
SceneAppPageState,
5+
SceneControlsSpacer,
6+
SceneFlexItem,
7+
SceneFlexLayout,
8+
SceneObject,
9+
SceneQueryRunner,
10+
SceneTimePicker,
11+
SceneTimeRange,
12+
SceneToolbarInput,
13+
VizPanel,
14+
} from '@grafana/scenes';
15+
import { SceneRadioToggle } from '../../components/SceneRadioToggle';
16+
import { DATASOURCE_REF } from '../../constants';
17+
import { getQueryRunnerWithRandomWalkQuery } from '../utils';
18+
import { HiddenForTimeRangeBehavior } from './HiddenForTimeRangeBehavior';
19+
import { HiddenWhenNoDataBehavior } from './HiddenWhenNoDataBehavior';
20+
import { ShowBasedOnConditionBehavior } from './ShowBasedOnConditionBehavior';
21+
22+
export function getBehaviorsDemo(defaults: SceneAppPageState) {
23+
return new SceneAppPage({
24+
...defaults,
25+
subTitle: 'Behaviors can augment any scene object with new runtime behaviors and state logic',
26+
getScene: () => {
27+
const queryRunner = getQueryRunnerWithRandomWalkQuery({
28+
seriesCount: 2,
29+
alias: '__server_names',
30+
scenarioId: 'random_walk',
31+
});
32+
33+
return new EmbeddedScene({
34+
$timeRange: new SceneTimeRange(),
35+
controls: [
36+
new SceneToolbarInput({
37+
value: '2',
38+
onChange: (newValue) => {
39+
queryRunner.setState({
40+
queries: [
41+
{
42+
...queryRunner.state.queries[0],
43+
seriesCount: newValue,
44+
},
45+
],
46+
});
47+
queryRunner.runQueries();
48+
},
49+
}),
50+
new SceneRadioToggle({
51+
key: 'toggle',
52+
options: [
53+
{ value: 'visible', label: 'Show text panel' },
54+
{ value: 'hidden', label: 'Hide text panel' },
55+
],
56+
value: 'hidden',
57+
}),
58+
new SceneControlsSpacer(),
59+
new SceneTimePicker({ isOnCanvas: true }),
60+
],
61+
body: new SceneFlexLayout({
62+
direction: 'column',
63+
children: [
64+
new SceneFlexItem({
65+
$behaviors: [
66+
new ShowBasedOnConditionBehavior({
67+
references: ['toggle'],
68+
condition: (toogle: SceneRadioToggle) => toogle.state.value === 'visible',
69+
}),
70+
],
71+
body: new VizPanel({
72+
pluginId: 'text',
73+
options: { content: 'This panel can be hidden with a toggle!' },
74+
}),
75+
}),
76+
new SceneFlexItem({
77+
$behaviors: [new HiddenForTimeRangeBehavior({ greaterThan: 'now-2d' })],
78+
// this needs to start out hidden as the behavior activates after the body
79+
isHidden: true,
80+
body: new VizPanel({
81+
title: 'Hidden for time ranges > 2d',
82+
key: 'Hidden for time ranges > 2d',
83+
$behaviors: [logEventsBehavior],
84+
$data: new SceneQueryRunner({
85+
key: 'Hidden for time range query runner',
86+
$behaviors: [logEventsBehavior],
87+
queries: [
88+
{
89+
refId: 'A',
90+
datasource: DATASOURCE_REF,
91+
scenarioId: 'random_walk',
92+
},
93+
],
94+
}),
95+
}),
96+
}),
97+
new SceneFlexItem({
98+
$behaviors: [new HiddenWhenNoDataBehavior()],
99+
$data: queryRunner,
100+
body: new VizPanel({ title: 'Hidden when no time series' }),
101+
}),
102+
],
103+
}),
104+
});
105+
},
106+
});
107+
}
108+
109+
function logEventsBehavior(sceneObject: SceneObject) {
110+
console.log(`[SceneObjectEvent]: ${sceneObject.constructor?.name} ${sceneObject.state.key} activated!`);
111+
112+
sceneObject.subscribeToState((state) => {
113+
console.log(`[SceneObjectEvent]: ${sceneObject.constructor?.name} ${sceneObject.state.key} state changed!`, state);
114+
});
115+
116+
return () => {
117+
console.log(`[SceneObjectEvent]: ${sceneObject.constructor?.name} ${sceneObject.state.key} deactivated!`);
118+
};
119+
}

packages/scenes-app/src/demos/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SceneAppPage, SceneAppPageState } from '@grafana/scenes';
2+
import { getBehaviorsDemo } from './behaviors/behaviorsDemo';
23
import { getDynamicPageDemo } from './dynamicPage';
34
import { getFlexLayoutTest } from './flexLayout';
45
import { getGridLayoutTest } from './grid';
@@ -31,5 +32,6 @@ export function getDemos(): DemoDescriptor[] {
3132
{ title: 'With drilldowns', getPage: getDrilldownsAppPageScene },
3233
{ title: 'Query editor', getPage: getQueryEditorDemo },
3334
{ title: 'Dynamic page', getPage: getDynamicPageDemo },
35+
{ title: 'Behaviors demo', getPage: getBehaviorsDemo },
3436
];
3537
}

packages/scenes/src/components/layout/SceneFlexLayout.tsx

+18-7
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ export class SceneFlexLayout extends SceneObjectBase<SceneFlexLayoutState> imple
2828
}
2929

3030
function SceneFlexLayoutRenderer({ model, parentDirection }: SceneFlexItemRenderProps<SceneFlexLayout>) {
31-
const { direction = 'row', children, wrap } = model.useState();
31+
const { direction = 'row', children, wrap, isHidden } = model.useState();
32+
33+
if (isHidden) {
34+
return null;
35+
}
3236

3337
let style: CSSProperties = {
3438
display: 'flex',
@@ -57,7 +61,7 @@ function SceneFlexLayoutRenderer({ model, parentDirection }: SceneFlexItemRender
5761
);
5862
}
5963

60-
interface SceneFlexItemPlacement {
64+
export interface SceneFlexItemPlacement {
6165
flexGrow?: CSSProperties['flexGrow'];
6266
alignSelf?: CSSProperties['alignSelf'];
6367
width?: CSSProperties['width'];
@@ -68,10 +72,16 @@ interface SceneFlexItemPlacement {
6872
maxHeight?: CSSProperties['maxHeight'];
6973
xSizing?: 'fill' | 'content';
7074
ySizing?: 'fill' | 'content';
75+
/**
76+
* True when the item should rendered but not visible.
77+
* Useful for conditional display of layout items
78+
*/
79+
isHidden?: boolean;
7180
}
7281

7382
interface SceneFlexItemState extends SceneFlexItemPlacement, SceneObjectState {
7483
body: SceneObject | undefined;
84+
isHidden?: boolean;
7585
}
7686

7787
interface SceneFlexItemRenderProps<T> extends SceneComponentProps<T> {
@@ -83,7 +93,12 @@ export class SceneFlexItem extends SceneObjectBase<SceneFlexItemState> {
8393
}
8494

8595
function SceneFlexItemRenderer({ model, parentDirection }: SceneFlexItemRenderProps<SceneFlexItem>) {
86-
const { body } = model.useState();
96+
const { body, isHidden } = model.useState();
97+
98+
if (!body || isHidden) {
99+
return null;
100+
}
101+
87102
let style: CSSProperties = {};
88103

89104
if (!parentDirection) {
@@ -92,10 +107,6 @@ function SceneFlexItemRenderer({ model, parentDirection }: SceneFlexItemRenderPr
92107

93108
style = getFlexItemItemStyles(parentDirection, model);
94109

95-
if (!body) {
96-
return null;
97-
}
98-
99110
return (
100111
<div style={style}>
101112
<body.Component model={body} />

packages/scenes/src/components/layout/grid/SceneGridLayout.test.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('SceneGridLayout', () => {
147147
],
148148
}),
149149
],
150-
isLazy: false
150+
isLazy: false,
151151
}),
152152
});
153153

@@ -192,7 +192,7 @@ describe('SceneGridLayout', () => {
192192
body: new TestObject({}),
193193
}),
194194
],
195-
isLazy: false
195+
isLazy: false,
196196
});
197197
layout.onDragStop(
198198
[
@@ -412,7 +412,7 @@ describe('SceneGridLayout', () => {
412412

413413
const layout = new SceneGridLayout({
414414
children: [rowA, panelOutsideARow, rowB],
415-
isLazy: false
415+
isLazy: false,
416416
});
417417

418418
layout.toggleRow(rowA);

0 commit comments

Comments
 (0)