Scene: POC for a future dashboard model and runtime (#50980)

* Playing around

* This is getting interesting

* Updates

* Updated

* Observable experiments

* This is tricky

* VizPanel panel renderer

* New model progress

* Maybe this could be something

* Updated

* Rename

* updates

* Updated

* Query runners? not sure

* Updated

* updates

* flex box layout starting to work

* Testing

* Tested an action

* Parent context sort of working

* Progress

* Progress

* Updated

* Starting to work

* Things are working

* Scene list, nested scene demo

* Progress on repeats

* Moving things

* Pretty big progress

* More things working

* Great progress

* Progress

* Name changing

* Minor tweaks

* Simplified sizing

* Move toggleDirection to SceneFlexLayout

* add feature flag (#50990)

* removed new useObservable hook

* Rename folder and feature toggle to scenes

* Caching scenes so you can go back to another scene without having to re-query data

* Fix issue with subs on re-mount

* Fixing test

* Added SceneCanvasText to play around with layout elements with size based on content

* Scene: Edit mode and component edit wrapper that handles selection  (#51078)

* First step for scene variables

* Started playing around with a scene edit mode

* Better way to set component

* Progress on edit mode

* Update

* Progress on edit mode

* Progress on editor

* Progress on editor

* Updates

* More working

* Progress

* Minor update

* removed unnessary file

* Moving things around

* Updated

* Making time range separate from time picker

* minor rename of methods

* The most basic variable start

* Minor renames

* Fixed interpolate issue if not found at closest level

* An embryo of event model and url sync handling

* Update url sync types

* Removed unnessary any type arg

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Torkel Ödegaard 2022-07-07 08:53:02 +02:00 committed by GitHub
parent 052132be46
commit 935334cbda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1825 additions and 4 deletions

View File

@ -5726,6 +5726,56 @@ exports[`better eslint`] = {
"public/app/features/sandbox/TestStuffPage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/scenes/components/SceneFlexLayout.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/scenes/core/SceneComponentEditWrapper.tsx: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"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/features/scenes/core/SceneObjectBase.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Do not use any type assertions.", "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.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
],
"public/app/features/scenes/core/SceneTimeRange.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/scenes/core/events.ts: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"]
],
"public/app/features/scenes/core/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"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"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/scenes/querying/SceneQueryRunner.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"],
[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/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/search/components/SearchCard.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],

View File

@ -56,6 +56,7 @@ export interface FeatureToggles {
autoMigrateGraphPanels?: boolean;
prometheusWideSeries?: boolean;
canvasPanelNesting?: boolean;
scenes?: boolean;
useLegacyHeatmapPanel?: boolean;
cloudMonitoringExperimentalUI?: boolean;
logRequestsInstrumentedAsUnknown?: boolean;

View File

@ -11,7 +11,7 @@ import { AbsoluteTimeRange, FieldConfigSource, PanelData } from '@grafana/data';
* @internal
*/
export interface PanelRendererProps<P extends object = any, F extends object = any> {
data: PanelData;
data?: PanelData;
pluginId: string;
title: string;
options?: Partial<P>;

View File

@ -459,6 +459,15 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
})
}
if hs.Features.IsEnabled(featuremgmt.FlagScenes) {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Scenes",
Id: "scenes",
Url: hs.Cfg.AppSubURL + "/scenes",
Icon: "apps",
})
}
if hasEditPerm {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,

View File

@ -227,6 +227,12 @@ var (
State: FeatureStateAlpha,
FrontendOnly: true,
},
{
Name: "scenes",
Description: "Experimental framework to build interactive dashboards",
State: FeatureStateAlpha,
FrontendOnly: true,
},
{
Name: "useLegacyHeatmapPanel",
Description: "Continue to use the angular/flot based heatmap panel",

View File

@ -167,6 +167,10 @@ const (
// Allow elements nesting
FlagCanvasPanelNesting = "canvasPanelNesting"
// FlagScenes
// Experimental framework to build interactive dashboards
FlagScenes = "scenes"
// FlagUseLegacyHeatmapPanel
// Continue to use the angular/flot based heatmap panel
FlagUseLegacyHeatmapPanel = "useLegacyHeatmapPanel"

View File

@ -57,9 +57,12 @@ export const OptionsPaneCategory: FC<OptionsPaneCategoryProps> = React.memo(
const onToggle = useCallback(() => {
manualClickTime.current = Date.now();
updateQueryParams({
[CATEGORY_PARAM_NAME]: isExpanded ? undefined : id,
});
updateQueryParams(
{
[CATEGORY_PARAM_NAME]: isExpanded ? undefined : id,
},
true
);
setSavedState({ isExpanded: !isExpanded });
setIsExpanded(!isExpanded);
}, [setSavedState, setIsExpanded, updateQueryParams, isExpanded, id]);

View File

@ -0,0 +1,31 @@
// Libraries
import React, { FC } from 'react';
import { Stack } from '@grafana/experimental';
import { Card } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
// Types
import { getScenes } from './scenes';
export interface Props {}
export const SceneListPage: FC<Props> = ({}) => {
const scenes = getScenes();
return (
<Page>
<Page.Contents>
<Stack direction="column">
{scenes.map((scene) => (
<Card href={`/scenes/${scene.state.title}`} key={scene.state.title}>
<Card.Heading>{scene.state.title}</Card.Heading>
</Card>
))}
</Stack>
</Page.Contents>
</Page>
);
};
export default SceneListPage;

View File

@ -0,0 +1,24 @@
// Libraries
import React, { FC } from 'react';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getSceneByTitle } from './scenes';
export interface Props extends GrafanaRouteComponentProps<{ name: string }> {}
export const ScenePage: FC<Props> = (props) => {
const scene = getSceneByTitle(props.match.params.name);
if (!scene) {
return <h2>Scene not found</h2>;
}
return (
<div style={{ height: '100%', display: 'flex', width: '100%' }}>
<scene.Component model={scene} />
</div>
);
};
export default ScenePage;

View File

@ -0,0 +1,15 @@
import { Scene } from './Scene';
import { SceneFlexLayout } from './SceneFlexLayout';
describe('Scene', () => {
it('Simple scene', () => {
const scene = new Scene({
title: 'Hello',
layout: new SceneFlexLayout({
children: [],
}),
});
expect(scene.state.title).toBe('Hello');
});
});

View File

@ -0,0 +1,56 @@
import React from 'react';
import { PageToolbar, ToolbarButton } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectState, SceneObject } from '../core/types';
import { UrlSyncManager } from '../services/UrlSyncManager';
interface SceneState extends SceneObjectState {
title: string;
layout: SceneObject;
actions?: SceneObject[];
isEditing?: boolean;
}
export class Scene extends SceneObjectBase<SceneState> {
static Component = SceneRenderer;
urlSyncManager?: UrlSyncManager;
onMount() {
super.onMount();
this.urlSyncManager = new UrlSyncManager(this);
}
onUnmount() {
super.onUnmount();
this.urlSyncManager!.cleanUp();
}
}
function SceneRenderer({ model }: SceneComponentProps<Scene>) {
const { title, layout, actions = [], isEditing, $editor } = model.useState();
console.log('render scene');
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', flex: '1 1 0', minHeight: 0 }}>
<PageToolbar title={title}>
{actions.map((action) => (
<action.Component key={action.state.key} model={action} />
))}
{$editor && (
<ToolbarButton
icon="cog"
variant={isEditing ? 'primary' : 'default'}
onClick={() => model.setState({ isEditing: !model.state.isEditing })}
/>
)}
</PageToolbar>
<div style={{ flexGrow: 1, display: 'flex', padding: '16px', gap: '8px', paddingTop: 0, overflow: 'auto' }}>
<layout.Component model={layout} isEditing={isEditing} />
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import React, { CSSProperties } from 'react';
import { Field, Input } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectState } from '../core/types';
export interface SceneCanvasTextState extends SceneObjectState {
text: string;
fontSize?: number;
align?: 'left' | 'center' | 'right';
}
export class SceneCanvasText extends SceneObjectBase<SceneCanvasTextState> {
static Editor = Editor;
static Component = ({ model }: SceneComponentProps<SceneCanvasText>) => {
const { text, fontSize = 20, align = 'left' } = model.useState();
const style: CSSProperties = {
fontSize: fontSize,
display: 'flex',
flexGrow: 1,
alignItems: 'center',
padding: 16,
justifyContent: align,
};
return <div style={style}>{text}</div>;
};
}
function Editor({ model }: SceneComponentProps<SceneCanvasText>) {
const { fontSize } = model.useState();
return (
<Field label="Font size">
<Input
type="number"
defaultValue={fontSize}
onBlur={(evt) => model.setState({ fontSize: parseInt(evt.currentTarget.value, 10) })}
/>
</Field>
);
}

View File

@ -0,0 +1,110 @@
import React, { CSSProperties } from 'react';
import { Field, RadioButtonGroup } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObject, SceneObjectSize, SceneObjectState, SceneLayoutState, SceneComponentProps } from '../core/types';
export type FlexLayoutDirection = 'column' | 'row';
interface SceneFlexLayoutState extends SceneObjectState, SceneLayoutState {
direction?: FlexLayoutDirection;
}
export class SceneFlexLayout extends SceneObjectBase<SceneFlexLayoutState> {
static Component = FlexLayoutRenderer;
static Editor = FlexLayoutEditor;
toggleDirection() {
this.setState({
direction: this.state.direction === 'row' ? 'column' : 'row',
});
}
}
function FlexLayoutRenderer({ model, isEditing }: SceneComponentProps<SceneFlexLayout>) {
const { direction = 'row', children } = model.useState();
return (
<div style={{ flexGrow: 1, flexDirection: direction, display: 'flex', gap: '8px' }}>
{children.map((item) => (
<FlexLayoutChildComponent key={item.state.key} item={item} direction={direction} isEditing={isEditing} />
))}
</div>
);
}
function FlexLayoutChildComponent({
item,
direction,
isEditing,
}: {
item: SceneObject<SceneObjectState>;
direction: FlexLayoutDirection;
isEditing?: boolean;
}) {
const { size } = item.useState();
return (
<div style={getItemStyles(direction, size)}>
<item.Component model={item} isEditing={isEditing} />
</div>
);
}
function getItemStyles(direction: FlexLayoutDirection, sizing: SceneObjectSize = {}) {
const { xSizing = 'fill', ySizing = 'fill' } = sizing;
const style: CSSProperties = {
display: 'flex',
flexDirection: direction,
minWidth: sizing.minWidth,
minHeight: sizing.minHeight,
};
if (direction === 'column') {
if (sizing.height) {
style.height = sizing.height;
} else {
style.flexGrow = ySizing === 'fill' ? 1 : 0;
}
if (sizing.width) {
style.width = sizing.width;
} else {
style.alignSelf = xSizing === 'fill' ? 'stretch' : 'flex-start';
}
} else {
if (sizing.height) {
style.height = sizing.height;
} else {
style.alignSelf = ySizing === 'fill' ? 'stretch' : 'flex-start';
}
if (sizing.width) {
style.width = sizing.width;
} else {
style.flexGrow = xSizing === 'fill' ? 1 : 0;
}
}
return style;
}
function FlexLayoutEditor({ model }: SceneComponentProps<SceneFlexLayout>) {
const { direction = 'row' } = model.useState();
const options = [
{ icon: 'arrow-right', value: 'row' },
{ icon: 'arrow-down', value: 'column' },
];
return (
<Field label="Direction">
<RadioButtonGroup
options={options}
value={direction}
onChange={(value) => model.setState({ direction: value as FlexLayoutDirection })}
/>
</Field>
);
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import { LoadingState, PanelData } from '@grafana/data';
import { SceneDataNode } from '../core/SceneDataNode';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObject, SceneObjectList, SceneObjectState, SceneLayoutState } from '../core/types';
interface RepeatOptions extends SceneObjectState {
layout: SceneObject<SceneLayoutState>;
}
export class ScenePanelRepeater extends SceneObjectBase<RepeatOptions> {
onMount() {
super.onMount();
this.subs.add(
this.getData().subscribe({
next: (data) => {
if (data.data?.state === LoadingState.Done) {
this.performRepeat(data.data);
}
},
})
);
}
performRepeat(data: PanelData) {
// assume parent is a layout
const firstChild = this.state.layout.state.children[0]!;
const newChildren: SceneObjectList = [];
for (const series of data.series) {
const clone = firstChild.clone({
key: `${newChildren.length}`,
$data: new SceneDataNode({
data: {
...data,
series: [series],
},
}),
});
newChildren.push(clone);
}
this.state.layout.setState({ children: newChildren });
}
static Component = ({ model, isEditing }: SceneComponentProps<ScenePanelRepeater>) => {
const { layout } = model.useState();
return <layout.Component model={layout} isEditing={isEditing} />;
};
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { RefreshPicker, ToolbarButtonRow } from '@grafana/ui';
import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectState } from '../core/types';
export interface SceneTimePickerState extends SceneObjectState {
hidePicker?: boolean;
}
export class SceneTimePicker extends SceneObjectBase<SceneTimePickerState> {
static Component = SceneTimePickerRenderer;
}
function SceneTimePickerRenderer({ model }: SceneComponentProps<SceneTimePicker>) {
const { hidePicker } = model.useState();
const timeRange = model.getTimeRange();
const timeRangeState = timeRange.useState();
if (hidePicker) {
return null;
}
return (
<ToolbarButtonRow>
<TimePickerWithHistory
value={timeRangeState}
onChange={timeRange.onTimeRangeChange}
timeZone={'browser'}
fiscalYearStartMonth={0}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
onChangeTimeZone={() => {}}
onChangeFiscalYearStartMonth={() => {}}
/>
<RefreshPicker onRefresh={timeRange.onRefresh} onIntervalChanged={timeRange.onIntervalChanged} />
</ToolbarButtonRow>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { IconName, Input, ToolbarButton } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectState } from '../core/types';
export interface ToolbarButtonState extends SceneObjectState {
icon: IconName;
onClick: () => void;
}
export class SceneToolbarButton extends SceneObjectBase<ToolbarButtonState> {
static Component = ({ model }: SceneComponentProps<SceneToolbarButton>) => {
const state = model.useState();
return <ToolbarButton onClick={state.onClick} icon={state.icon} />;
};
}
export interface SceneToolbarInputState extends SceneObjectState {
value?: string;
onChange: (value: number) => void;
}
export class SceneToolbarInput extends SceneObjectBase<SceneToolbarInputState> {
static Component = ({ model }: SceneComponentProps<SceneToolbarInput>) => {
const state = model.useState();
return (
<Input
defaultValue={state.value}
onBlur={(evt) => {
model.state.onChange(parseInt(evt.currentTarget.value, 10));
}}
/>
);
};
}

View File

@ -0,0 +1,80 @@
import React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { AbsoluteTimeRange, FieldConfigSource, toUtc } from '@grafana/data';
import { PanelRenderer } from '@grafana/runtime';
import { Field, PanelChrome, Input } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectState } from '../core/types';
export interface VizPanelState extends SceneObjectState {
title?: string;
pluginId: string;
options?: object;
fieldConfig?: FieldConfigSource;
}
export class VizPanel extends SceneObjectBase<VizPanelState> {
static Component = ScenePanelRenderer;
static Editor = VizPanelEditor;
onSetTimeRange = (timeRange: AbsoluteTimeRange) => {
const sceneTimeRange = this.getTimeRange();
sceneTimeRange.setState({
raw: {
from: toUtc(timeRange.from),
to: toUtc(timeRange.to),
},
from: toUtc(timeRange.from),
to: toUtc(timeRange.to),
});
};
}
function ScenePanelRenderer({ model }: SceneComponentProps<VizPanel>) {
const { title, pluginId, options, fieldConfig } = model.useState();
const { data } = model.getData().useState();
return (
<AutoSizer>
{({ width, height }) => {
if (width < 3 || height < 3) {
return null;
}
return (
<PanelChrome title={title} width={width} height={height}>
{(innerWidth, innerHeight) => (
<>
<PanelRenderer
title="Raw data"
pluginId={pluginId}
width={innerWidth}
height={innerHeight}
data={data}
options={options}
fieldConfig={fieldConfig}
onOptionsChange={() => {}}
onChangeTimeRange={model.onSetTimeRange}
/>
</>
)}
</PanelChrome>
);
}}
</AutoSizer>
);
}
ScenePanelRenderer.displayName = 'ScenePanelRenderer';
function VizPanelEditor({ model }: SceneComponentProps<VizPanel>) {
const { title } = model.useState();
return (
<Field label="Title">
<Input defaultValue={title} onBlur={(evt) => model.setState({ title: evt.currentTarget.value })} />
</Field>
);
}

View File

@ -0,0 +1,82 @@
import { css } from '@emotion/css';
import React, { CSSProperties } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { SceneObjectBase } from './SceneObjectBase';
import { SceneComponentProps } from './types';
export function SceneComponentEditWrapper<T extends SceneObjectBase<any>>({
model,
isEditing,
}: SceneComponentProps<T>) {
const Component = (model as any).constructor['Component'] ?? EmptyRenderer;
const inner = <Component model={model} isEditing={isEditing} />;
model.useMount();
if (!isEditing) {
return inner;
}
return <SceneComponentEditingWrapper model={model}>{inner}</SceneComponentEditingWrapper>;
}
export function SceneComponentEditingWrapper<T extends SceneObjectBase<any>>({
model,
children,
}: {
model: T;
children: React.ReactNode;
}) {
const styles = useStyles2(getStyles);
const editor = model.getSceneEditor();
const { hoverObject, selectedObject } = editor.useState();
const onMouseEnter = () => editor.onMouseEnterObject(model);
const onMouseLeave = () => editor.onMouseLeaveObject(model);
const onClick = (evt: React.MouseEvent) => {
evt.stopPropagation();
editor.onSelectObject(model);
};
const style: CSSProperties = {};
let className = styles.wrapper;
if (hoverObject?.ref === model) {
className += ' ' + styles.hover;
}
if (selectedObject?.ref === model) {
className += ' ' + styles.selected;
}
return (
<div style={style} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick}>
{children}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
display: 'flex',
flexGrow: 1,
padding: 8,
border: `1px dashed ${theme.colors.primary.main}`,
cursor: 'pointer',
}),
hover: css({
border: `1px solid ${theme.colors.primary.border}`,
}),
selected: css({
border: `1px solid ${theme.colors.error.border}`,
}),
};
};
function EmptyRenderer<T>(_: SceneComponentProps<T>): React.ReactElement | null {
return null;
}

View File

@ -0,0 +1,10 @@
import { PanelData } from '@grafana/data';
import { SceneObjectBase } from './SceneObjectBase';
import { SceneObjectState } from './types';
export interface SceneDataNodeState extends SceneObjectState {
data?: PanelData;
}
export class SceneDataNode extends SceneObjectBase<SceneDataNodeState> {}

View File

@ -0,0 +1,68 @@
import { SceneObjectBase } from './SceneObjectBase';
import { SceneObject, SceneObjectList, SceneObjectState } from './types';
interface TestSceneState extends SceneObjectState {
name?: string;
nested?: SceneObject<TestSceneState>;
children?: SceneObjectList;
actions?: SceneObjectList;
}
class TestScene extends SceneObjectBase<TestSceneState> {}
describe('SceneObject', () => {
it('Can clone', () => {
const scene = new TestScene({
nested: new TestScene({
name: 'nested',
}),
children: [
new TestScene({
name: 'layout child',
}),
],
});
scene.state.nested?.onMount();
const clone = scene.clone();
expect(clone).not.toBe(scene);
expect(clone.state.nested).not.toBe(scene.state.nested);
expect(clone.state.nested?.isMounted).toBe(undefined);
expect(clone.state.children![0]).not.toBe(scene.state.children![0]);
});
it('SceneObject should have parent when added to container', () => {
const scene = new TestScene({
nested: new TestScene({
name: 'nested',
}),
children: [
new TestScene({
name: 'layout child',
}),
],
actions: [
new TestScene({
name: 'layout child',
}),
],
});
expect(scene.parent).toBe(undefined);
expect(scene.state.nested?.parent).toBe(scene);
expect(scene.state.children![0].parent).toBe(scene);
expect(scene.state.actions![0].parent).toBe(scene);
});
it('Can clone with state change', () => {
const scene = new TestScene({
nested: new TestScene({
name: 'nested',
}),
});
const clone = scene.clone({ name: 'new name' });
expect(clone.state.name).toBe('new name');
});
});

View File

@ -0,0 +1,220 @@
import { useEffect } from 'react';
import { useObservable } from 'react-use';
import { Observer, Subject, Subscription } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EventBusSrv } from '@grafana/data';
import { SceneComponentEditWrapper } from './SceneComponentEditWrapper';
import { SceneObjectStateChangedEvent } from './events';
import {
SceneDataState,
SceneObject,
SceneLayoutState,
SceneObjectState,
SceneComponent,
SceneEditor,
SceneObjectList,
SceneTimeRange,
} from './types';
export abstract class SceneObjectBase<TState extends SceneObjectState = {}> implements SceneObject<TState> {
subject = new Subject<TState>();
state: TState;
parent?: SceneObjectBase<any>;
subs = new Subscription();
isMounted?: boolean;
events = new EventBusSrv();
constructor(state: TState) {
if (!state.key) {
state.key = uuidv4();
}
this.state = state;
this.subject.next(state);
this.setParent();
}
/**
* Used in render functions when rendering a SceneObject.
* Wraps the component in an EditWrapper that handles edit mode
*/
get Component(): SceneComponent<this> {
return SceneComponentEditWrapper;
}
/**
* Temporary solution, should be replaced by declarative options
*/
get Editor(): SceneComponent<this> {
return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent<this>;
}
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;
}
}
}
}
}
/** This function implements the Subscribable<TState> interface */
subscribe(observer: Partial<Observer<TState>>) {
return this.subject.subscribe(observer);
}
setState(update: Partial<TState>) {
const prevState = this.state;
this.state = {
...this.state,
...update,
};
this.setParent();
this.subject.next(this.state);
// broadcast state change. This is event is subscribed to by UrlSyncManager and UndoManager
this.getRoot().events.publish(
new SceneObjectStateChangedEvent({
prevState,
newState: this.state,
partialUpdate: update,
changedObject: this,
})
);
}
private getRoot(): SceneObject {
return !this.parent ? this : this.parent.getRoot();
}
onMount() {
this.isMounted = true;
const { $data } = this.state;
if ($data && !$data.isMounted) {
$data.onMount();
}
}
onUnmount() {
this.isMounted = false;
const { $data } = this.state;
if ($data && $data.isMounted) {
$data.onUnmount();
}
this.subs.unsubscribe();
this.subs = new Subscription();
}
/**
* The scene object needs to know when the react component is mounted to trigger query and other lazy actions
*/
useMount() {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!this.isMounted) {
this.onMount();
}
return () => {
if (this.isMounted) {
this.onUnmount();
}
};
}, []);
return this;
}
useState() {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useObservable(this.subject, this.state);
}
/**
* Will walk up the scene object graph to the closest $timeRange scene object
*/
getTimeRange(): SceneTimeRange {
const { $timeRange } = this.state;
if ($timeRange) {
return $timeRange;
}
if (this.parent) {
return this.parent.getTimeRange();
}
throw new Error('No time range found in scene tree');
}
/**
* Will walk up the scene object graph to the closest $data scene object
*/
getData(): SceneObject<SceneDataState> {
const { $data } = this.state;
if ($data) {
return $data;
}
if (this.parent) {
return this.parent.getData();
}
throw new Error('No data found in scene tree');
}
/**
* Will walk up the scene object graph to the closest $editor scene object
*/
getSceneEditor(): SceneEditor {
const { $editor } = this.state;
if ($editor) {
return $editor;
}
if (this.parent) {
return this.parent.getSceneEditor();
}
throw new Error('No editor found in scene tree');
}
/**
* Will create new SceneItem with shalled cloned state, but all states items of type SceneItem are deep cloned
*/
clone(withState?: Partial<TState>): this {
const clonedState = { ...this.state };
// Clone any SceneItems in state
for (const key in clonedState) {
const propValue = clonedState[key];
if (propValue instanceof SceneObjectBase) {
clonedState[key] = propValue.clone();
}
}
// Clone layout children
const layout = this.state as any as SceneLayoutState;
if (layout.children) {
const newChildren: SceneObjectList = [];
for (const child of layout.children) {
newChildren.push(child.clone());
}
(clonedState as any).children = newChildren;
}
Object.assign(clonedState, withState);
return new (this.constructor as any)(clonedState);
}
}

View File

@ -0,0 +1,29 @@
import { TimeRange, UrlQueryMap } from '@grafana/data';
import { SceneObjectBase } from './SceneObjectBase';
import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types';
export class SceneTimeRange extends SceneObjectBase<SceneTimeRangeState> implements SceneObjectWithUrlSync {
onTimeRangeChange = (timeRange: TimeRange) => {
this.setState(timeRange);
};
onRefresh = () => {
// TODO re-eval time range
this.setState({ ...this.state });
};
onIntervalChanged = (_: string) => {};
/** These url sync functions are only placeholders for something more sophisticated */
getUrlState() {
return {
from: this.state.raw.from,
to: this.state.raw.to,
} as any;
}
updateFromUrl(values: UrlQueryMap) {
// TODO
}
}

View File

@ -0,0 +1,14 @@
import { BusEventWithPayload } from '@grafana/data';
import { SceneObject } from './types';
export interface SceneObjectStateChangedPayload {
prevState: any;
newState: any;
partialUpdate: any;
changedObject: SceneObject;
}
export class SceneObjectStateChangedEvent extends BusEventWithPayload<SceneObjectStateChangedPayload> {
static type = 'scene-object-state-change';
}

View File

@ -0,0 +1,122 @@
import React from 'react';
import { Subscribable } from 'rxjs';
import { EventBus, PanelData, TimeRange, UrlQueryMap } from '@grafana/data';
import { SceneVariableSet } from '../variables/types';
export interface SceneObjectState {
key?: string;
size?: SceneObjectSize;
$timeRange?: SceneTimeRange;
$data?: SceneObject<SceneDataState>;
$editor?: SceneEditor;
$variables?: SceneVariableSet;
}
export interface SceneObjectSize {
width?: number | string;
height?: number | string;
xSizing?: 'fill' | 'content';
ySizing?: 'fill' | 'content';
x?: number;
y?: number;
minWidth?: number | string;
minHeight?: number | string;
}
export interface SceneComponentProps<T> {
model: T;
isEditing?: boolean;
}
export type SceneComponent<TModel> = React.FunctionComponent<SceneComponentProps<TModel>>;
export interface SceneDataState extends SceneObjectState {
data?: PanelData;
}
export interface SceneObject<TState extends SceneObjectState = SceneObjectState> extends Subscribable<TState> {
/** The current state */
state: TState;
/** True when there is a React component mounted for this Object */
isMounted?: boolean;
/** SceneObject parent */
parent?: SceneObject;
/** Currently only used from root to broadcast events */
events: EventBus;
/** Utility hook that wraps useObservable. Used by React components to subscribes to state changes */
useState(): TState;
/** How to modify state */
setState(state: Partial<TState>): void;
/** Utility hook for main component so that object knows when it's mounted */
useMount(): this;
/** Called when component mounts. A place to register event listeners add subscribe to state changes */
onMount(): void;
/** Called when component unmounts. Unsubscribe to events */
onUnmount(): void;
/** Get the scene editor */
getSceneEditor(): SceneEditor;
/** Returns a deep clone this object and all it's children */
clone(state?: Partial<TState>): this;
/** A React component to use for rendering the object */
Component(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null;
/** To be replaced by declarative method */
Editor(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null;
}
export type SceneObjectList<T = SceneObjectState> = Array<SceneObject<T>>;
export interface SceneLayoutState extends SceneObjectState {
children: SceneObjectList;
}
export type SceneLayout<T extends SceneLayoutState = SceneLayoutState> = SceneObject<T>;
export interface SceneEditorState extends SceneObjectState {
hoverObject?: SceneObjectRef;
selectedObject?: SceneObjectRef;
}
export interface SceneEditor extends SceneObject<SceneEditorState> {
onMouseEnterObject(model: SceneObject): void;
onMouseLeaveObject(model: SceneObject): void;
onSelectObject(model: SceneObject): void;
}
export interface SceneTimeRangeState extends SceneObjectState, TimeRange {}
export interface SceneTimeRange extends SceneObject<SceneTimeRangeState> {
onTimeRangeChange(timeRange: TimeRange): void;
onIntervalChanged(interval: string): void;
onRefresh(): void;
}
export interface SceneObjectRef {
ref: SceneObject;
}
export function isSceneObject(obj: any): obj is SceneObject {
return obj.useState !== undefined;
}
/** These functions are still just temporary until this get's refined */
export interface SceneObjectWithUrlSync extends SceneObject {
getUrlState(): UrlQueryMap;
updateFromUrl(values: UrlQueryMap): void;
}
export function isSceneObjectWithUrlSync(obj: any): obj is SceneObjectWithUrlSync {
return obj.getUrlState !== undefined;
}

View File

@ -0,0 +1,70 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneEditorState, SceneEditor, SceneObject, SceneComponentProps, SceneComponent } from '../core/types';
import { SceneObjectEditor } from './SceneObjectEditor';
import { SceneObjectTree } from './SceneObjectTree';
export class SceneEditManager extends SceneObjectBase<SceneEditorState> implements SceneEditor {
static Component = SceneEditorRenderer;
get Component(): SceneComponent<this> {
return SceneEditorRenderer;
}
onMouseEnterObject(model: SceneObject) {
this.setState({ hoverObject: { ref: model } });
}
onMouseLeaveObject(model: SceneObject) {
if (model.parent) {
this.setState({ hoverObject: { ref: model.parent } });
} else {
this.setState({ hoverObject: undefined });
}
}
onSelectObject(model: SceneObject) {
this.setState({ selectedObject: { ref: model } });
}
}
function SceneEditorRenderer({ model, isEditing }: SceneComponentProps<SceneEditManager>) {
const { selectedObject } = model.useState();
const styles = useStyles2(getStyles);
if (!isEditing) {
return null;
}
return (
<div className={styles.container}>
<div className={styles.tree}>
<SceneObjectTree node={model.parent!} selectedObject={selectedObject?.ref} />
</div>
{selectedObject && <SceneObjectEditor model={selectedObject.ref} />}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
flexGrow: 0,
border: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.primary,
width: theme.spacing(40),
cursor: 'pointer',
flexDirection: 'column',
}),
tree: css({
padding: theme.spacing(0.25, 1),
}),
};
};

View File

@ -0,0 +1,17 @@
import React from 'react';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { SceneObject } from '../core/types';
export interface Props {
model: SceneObject;
}
export function SceneObjectEditor({ model }: Props) {
return (
<OptionsPaneCategory id="props" title="Properties" forceOpen={1}>
<model.Editor model={model} key={model.state.key} />
</OptionsPaneCategory>
);
}

View File

@ -0,0 +1,81 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { SceneObject, SceneLayoutState, SceneObjectList, isSceneObject } from '../core/types';
export interface Props {
node: SceneObject;
selectedObject?: SceneObject;
}
export function SceneObjectTree({ node, selectedObject }: Props) {
const styles = useStyles2(getStyles);
const state = node.useState();
let children: SceneObjectList = [];
for (const propKey of Object.keys(state)) {
const propValue = (state as any)[propKey];
if (isSceneObject(propValue)) {
children.push(propValue);
}
}
let layoutChildren = (state as SceneLayoutState).children;
if (layoutChildren) {
for (const child of layoutChildren) {
children.push(child);
}
}
const name = node.constructor.name;
const isSelected = selectedObject === node;
const onSelectNode = () => node.getSceneEditor().onSelectObject(node);
return (
<div className={styles.node}>
<div className={styles.header} onClick={onSelectNode}>
<div className={styles.icon}>{children.length > 0 && <Icon name="angle-down" size="sm" />}</div>
<div className={cx(styles.name, isSelected && styles.selected)}>{name}</div>
</div>
{children.length > 0 && (
<div className={styles.children}>
{children.map((child) => (
<SceneObjectTree node={child} selectedObject={selectedObject} key={child.state.key} />
))}
</div>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
node: css({
display: 'flex',
flexGrow: 0,
cursor: 'pointer',
flexDirection: 'column',
padding: '2px 4px',
}),
header: css({
display: 'flex',
fontWeight: 500,
}),
name: css({}),
selected: css({
color: theme.colors.error.text,
}),
icon: css({
width: theme.spacing(3),
color: theme.colors.text.secondary,
}),
children: css({
display: 'flex',
flexDirection: 'column',
paddingLeft: 8,
}),
};
};

View File

@ -0,0 +1,133 @@
import { cloneDeep } from 'lodash';
import { Unsubscribable } from 'rxjs';
import {
CoreApp,
DataQuery,
DataQueryRequest,
DataSourceApi,
DataSourceRef,
PanelData,
rangeUtil,
ScopedVars,
TimeRange,
} from '@grafana/data';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getNextRequestId } from 'app/features/query/state/PanelQueryRunner';
import { runRequest } from 'app/features/query/state/runRequest';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObjectState } from '../core/types';
export interface QueryRunnerState extends SceneObjectState {
data?: PanelData;
queries: DataQueryExtended[];
}
export interface DataQueryExtended extends DataQuery {
[key: string]: any;
}
export class SceneQueryRunner extends SceneObjectBase<QueryRunnerState> {
private querySub?: Unsubscribable;
onMount() {
super.onMount();
const timeRange = this.getTimeRange();
this.subs.add(
timeRange.subscribe({
next: (timeRange) => {
this.runWithTimeRange(timeRange);
},
})
);
if (!this.state.data) {
this.runQueries();
}
}
onUnmount() {
super.onUnmount();
this.cleanUp();
}
cleanUp() {
if (this.querySub) {
this.querySub.unsubscribe();
this.querySub = undefined;
}
}
runQueries() {
const timeRange = this.getTimeRange();
this.runWithTimeRange(timeRange.state);
}
private async runWithTimeRange(timeRange: TimeRange) {
const queries = cloneDeep(this.state.queries);
const request: DataQueryRequest = {
app: CoreApp.Dashboard,
requestId: getNextRequestId(),
timezone: 'browser',
panelId: 1,
dashboardId: 1,
range: timeRange,
interval: '1s',
intervalMs: 1000,
targets: cloneDeep(this.state.queries),
maxDataPoints: 500,
scopedVars: {},
startTime: Date.now(),
};
try {
const ds = await getDataSource(queries[0].datasource!, request.scopedVars);
// Attach the data source name to each query
request.targets = request.targets.map((query) => {
if (!query.datasource) {
query.datasource = ds.getRef();
}
return query;
});
const lowerIntervalLimit = ds.interval;
const norm = rangeUtil.calculateInterval(timeRange, request.maxDataPoints ?? 1000, lowerIntervalLimit);
// make shallow copy of scoped vars,
// and add built in variables interval and interval_ms
request.scopedVars = Object.assign({}, request.scopedVars, {
__interval: { text: norm.interval, value: norm.interval },
__interval_ms: { text: norm.intervalMs.toString(), value: norm.intervalMs },
});
request.interval = norm.interval;
request.intervalMs = norm.intervalMs;
console.log('Query runner run');
this.querySub = runRequest(ds, request).subscribe({
next: (data) => {
console.log('set data', data, data.state);
this.setState({ data });
},
});
} catch (err) {
console.error('PanelQueryRunner Error', err);
}
}
}
async function getDataSource(
datasource: DataSourceRef | string | DataSourceApi | null,
scopedVars: ScopedVars
): Promise<DataSourceApi> {
if (datasource && (datasource as any).query) {
return datasource as DataSourceApi;
}
return await getDatasourceSrv().get(datasource as string, scopedVars);
}

View File

@ -0,0 +1,140 @@
import { getDefaultTimeRange } from '@grafana/data';
import { Scene } from '../components/Scene';
import { SceneCanvasText } from '../components/SceneCanvasText';
import { SceneFlexLayout } from '../components/SceneFlexLayout';
import { ScenePanelRepeater } from '../components/ScenePanelRepeater';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneToolbarInput } from '../components/SceneToolbarButton';
import { VizPanel } from '../components/VizPanel';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
export function getFlexLayoutTest(): Scene {
const scene = new Scene({
title: 'Flex layout test',
layout: new SceneFlexLayout({
direction: 'row',
children: [
new VizPanel({
pluginId: 'timeseries',
title: 'Dynamic height and width',
size: { minWidth: '70%' },
}),
new SceneFlexLayout({
// size: { width: 450 },
direction: 'column',
children: [
new VizPanel({
pluginId: 'timeseries',
title: 'Fill height',
}),
new VizPanel({
pluginId: 'timeseries',
title: 'Fill height',
}),
new SceneCanvasText({
text: 'Size to content',
fontSize: 20,
size: { ySizing: 'content' },
align: 'center',
}),
new VizPanel({
pluginId: 'timeseries',
title: 'Fixed height',
size: { height: 300 },
}),
],
}),
],
}),
$editor: new SceneEditManager({}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
$data: new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: {
uid: 'gdev-testdata',
type: 'testdata',
},
scenarioId: 'random_walk',
},
],
}),
actions: [new SceneTimePicker({})],
});
return scene;
}
export function getScenePanelRepeaterTest(): Scene {
const queryRunner = new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: {
uid: 'gdev-testdata',
type: 'testdata',
},
seriesCount: 5,
alias: '__server_names',
scenarioId: 'random_walk',
},
],
});
const scene = new Scene({
title: 'Panel repeater test',
layout: new ScenePanelRepeater({
layout: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexLayout({
size: { minHeight: 200 },
children: [
new VizPanel({
pluginId: 'timeseries',
title: 'Title',
options: {
legend: { displayMode: 'hidden' },
},
}),
new VizPanel({
size: { width: 300 },
pluginId: 'stat',
fieldConfig: { defaults: { displayName: 'Last' }, overrides: [] },
options: {
graphMode: 'none',
},
}),
],
}),
],
}),
}),
$editor: new SceneEditManager({}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
$data: queryRunner,
actions: [
new SceneToolbarInput({
value: '5',
onChange: (newValue) => {
queryRunner.setState({
queries: [
{
...queryRunner.state.queries[0],
seriesCount: newValue,
},
],
});
queryRunner.runQueries();
},
}),
new SceneTimePicker({}),
],
});
return scene;
}

View File

@ -0,0 +1,23 @@
import { Scene } from '../components/Scene';
import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo';
import { getNestedScene } from './nested';
export function getScenes(): Scene[] {
return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene()];
}
const cache: Record<string, Scene> = {};
export function getSceneByTitle(title: string) {
if (cache[title]) {
return cache[title];
}
const scene = getScenes().find((x) => x.state.title === title);
if (scene) {
cache[title] = scene;
}
return scene;
}

View File

@ -0,0 +1,73 @@
import { getDefaultTimeRange } from '@grafana/data';
import { Scene } from '../components/Scene';
import { SceneFlexLayout } from '../components/SceneFlexLayout';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { VizPanel } from '../components/VizPanel';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
export function getNestedScene(): Scene {
const scene = new Scene({
title: 'Nested Scene demo',
layout: new SceneFlexLayout({
direction: 'column',
children: [
new VizPanel({
key: '3',
pluginId: 'timeseries',
title: 'Panel 3',
}),
getInnerScene('Inner scene'),
],
}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
$data: new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: {
uid: 'gdev-testdata',
type: 'testdata',
},
scenarioId: 'random_walk',
},
],
}),
actions: [new SceneTimePicker({})],
});
return scene;
}
export function getInnerScene(title: string): Scene {
const scene = new Scene({
title: title,
layout: new SceneFlexLayout({
direction: 'row',
children: [
new VizPanel({
key: '3',
pluginId: 'timeseries',
title: 'Data',
}),
],
}),
$timeRange: new SceneTimeRange(getDefaultTimeRange()),
$data: new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: {
uid: 'gdev-testdata',
type: 'testdata',
},
scenarioId: 'random_walk',
},
],
}),
actions: [new SceneTimePicker({})],
});
return scene;
}

View File

@ -0,0 +1,36 @@
import { Location } from 'history';
import { Unsubscribable } from 'rxjs';
import { locationService } from '@grafana/runtime';
import { SceneObjectStateChangedEvent } from '../core/events';
import { isSceneObjectWithUrlSync, SceneObject } from '../core/types';
export class UrlSyncManager {
private locationListenerUnsub: () => void;
private stateChangeSub: Unsubscribable;
constructor(sceneRoot: SceneObject) {
this.stateChangeSub = sceneRoot.events.subscribe(SceneObjectStateChangedEvent, this.onStateChanged);
this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate);
}
onLocationUpdate = (location: Location) => {
// TODO: find any scene object whose state we need to update
};
onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => {
const changedObject = payload.changedObject;
if (!isSceneObjectWithUrlSync(changedObject)) {
return;
}
const urlUpdate = changedObject.getUrlState();
locationService.partial(urlUpdate, true);
};
cleanUp() {
this.stateChangeSub.unsubscribe();
this.locationListenerUnsub();
}
}

View File

@ -0,0 +1,43 @@
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObjectState } from '../core/types';
import { sceneTemplateInterpolator, SceneVariableManager, TextBoxSceneVariable } from './SceneVariableSet';
interface TestSceneState extends SceneObjectState {
nested?: TestScene;
}
class TestScene extends SceneObjectBase<TestSceneState> {}
describe('SceneObject with variables', () => {
it('Should be interpolate and use closest variable', () => {
const scene = new TestScene({
$variables: new SceneVariableManager({
variables: [
new TextBoxSceneVariable({
name: 'test',
current: { value: 'hello' },
}),
new TextBoxSceneVariable({
name: 'atRootOnly',
current: { value: 'RootValue' },
}),
],
}),
nested: new TestScene({
$variables: new SceneVariableManager({
variables: [
new TextBoxSceneVariable({
name: 'test',
current: { value: 'nestedValue' },
}),
],
}),
}),
});
expect(sceneTemplateInterpolator('${test}', scene)).toBe('hello');
expect(sceneTemplateInterpolator('${test}', scene.state.nested!)).toBe('nestedValue');
expect(sceneTemplateInterpolator('${atRootOnly}', scene.state.nested!)).toBe('RootValue');
});
});

View File

@ -0,0 +1,50 @@
import { variableRegex } from 'app/features/variables/utils';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObject } from '../core/types';
import { SceneVariable, SceneVariableSet, SceneVariableSetState, SceneVariableState } from './types';
export class TextBoxSceneVariable extends SceneObjectBase<SceneVariableState> implements SceneVariable {}
export class SceneVariableManager extends SceneObjectBase<SceneVariableSetState> implements SceneVariableSet {
getVariableByName(name: string): SceneVariable | undefined {
// TODO: Replace with index
return this.state.variables.find((x) => x.state.name === name);
}
}
export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) {
variableRegex.lastIndex = 0;
return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => {
const variableName = var1 || var2 || var3;
const variable = lookupSceneVariable(variableName, sceneObject);
if (!variable) {
return match;
}
return variable.state.current.value;
});
}
function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined {
const variables = sceneObject.state.$variables;
if (!variables) {
if (sceneObject.parent) {
return lookupSceneVariable(name, sceneObject.parent);
} else {
return null;
}
}
const found = variables.getVariableByName(name);
if (found) {
return found;
} else if (sceneObject.parent) {
return lookupSceneVariable(name, sceneObject.parent);
}
return null;
}

View File

@ -0,0 +1,24 @@
import { LoadingState } from '@grafana/data';
import { VariableHide } from 'app/features/variables/types';
import { SceneObject, SceneObjectState } from '../core/types';
export interface SceneVariableState extends SceneObjectState {
name: string;
hide?: VariableHide;
skipUrlSync?: boolean;
state?: LoadingState;
error?: any | null;
description?: string | null;
current: { value: string; text?: string };
}
export interface SceneVariable extends SceneObject<SceneVariableState> {}
export interface SceneVariableSetState extends SceneObjectState {
variables: SceneVariable[];
}
export interface SceneVariableSet extends SceneObject<SceneVariableSetState> {
getVariableByName(name: string): SceneVariable | undefined;
}

View File

@ -439,6 +439,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
),
},
...getDynamicDashboardRoutes(),
...getPluginCatalogRoutes(),
...getLiveRoutes(),
...getAlertingRoutes(),
@ -454,3 +455,19 @@ export function getAppRoutes(): RouteDescriptor[] {
// ...playlistRoutes,
];
}
export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
if (!cfg.featureToggles.scenes) {
return [];
}
return [
{
path: '/scenes',
component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/SceneListPage')),
},
{
path: '/scenes/:name',
component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/ScenePage')),
},
];
}