Scenes: Use @grafana/scenes (#60972)

* Use @grafana/scenes WIP

* Use @grafana/scenes package

* Use released @grafana/scenes

* Fix template_srv test
This commit is contained in:
Dominik Prokop
2023-01-10 03:30:53 -08:00
committed by GitHub
parent 35ad9e23ce
commit 5dbbaab3f1
94 changed files with 229 additions and 7621 deletions

View File

@@ -1,10 +1,9 @@
import { VizPanel, VizPanelState } from '@grafana/scenes';
import { GraphFieldConfig, TableFieldOptions } from '@grafana/schema';
import { PanelOptions as BarGaugePanelOptions } from 'app/plugins/panel/bargauge/models.gen';
import { PanelOptions as TablePanelOptions } from 'app/plugins/panel/table/models.gen';
import { TimeSeriesOptions } from 'app/plugins/panel/timeseries/types';
import { VizPanel, VizPanelState } from './VizPanel';
export type TypedVizPanelState<TOptions, TFieldConfig> = Omit<
Partial<VizPanelState<TOptions, TFieldConfig>>,
'pluginId'

View File

@@ -1,59 +0,0 @@
import { screen, render } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { configureStore } from '../../../store/configureStore';
import { NestedScene } from './NestedScene';
import { Scene } from './Scene';
import { SceneCanvasText } from './SceneCanvasText';
import { SceneFlexLayout } from './layout/SceneFlexLayout';
function setup() {
const store = configureStore();
const scene = new Scene({
title: 'Hello',
body: new SceneFlexLayout({
children: [
new NestedScene({
title: 'Nested title',
canRemove: true,
canCollapse: true,
body: new SceneFlexLayout({
children: [new SceneCanvasText({ text: 'SceneCanvasText' })],
}),
}),
],
}),
});
render(
<Provider store={store}>
<scene.Component model={scene} />
</Provider>
);
}
describe('NestedScene', () => {
it('Renders heading and layout', () => {
setup();
expect(screen.getByRole('heading', { name: 'Nested title' })).toBeInTheDocument();
expect(screen.getByText('SceneCanvasText')).toBeInTheDocument();
});
it('Can remove', async () => {
setup();
screen.getByRole('button', { name: 'Remove scene' }).click();
expect(screen.queryByRole('heading', { name: 'Nested title' })).not.toBeInTheDocument();
});
it('Can collapse and expand', async () => {
setup();
screen.getByRole('button', { name: 'Collapse scene' }).click();
expect(screen.queryByText('SceneCanvasText')).not.toBeInTheDocument();
screen.getByRole('button', { name: 'Expand scene' }).click();
expect(screen.getByText('SceneCanvasText')).toBeInTheDocument();
});
});

View File

@@ -1,113 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, ToolbarButton, useStyles2 } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneObject, SceneLayoutChildState, SceneComponentProps, SceneLayout } from '../core/types';
interface NestedSceneState extends SceneLayoutChildState {
title: string;
isCollapsed?: boolean;
canCollapse?: boolean;
canRemove?: boolean;
body: SceneLayout;
actions?: SceneObject[];
}
export class NestedScene extends SceneObjectBase<NestedSceneState> {
public static Component = NestedSceneRenderer;
public onToggle = () => {
this.setState({
isCollapsed: !this.state.isCollapsed,
placement: {
...this.state.placement,
ySizing: this.state.isCollapsed ? 'fill' : 'content',
},
});
};
/** Removes itself from its parent's children array */
public onRemove = () => {
const parent = this.parent!;
if ('children' in parent.state) {
parent.setState({
children: parent.state.children.filter((x) => x !== this),
});
}
};
}
export function NestedSceneRenderer({ model, isEditing }: SceneComponentProps<NestedScene>) {
const { title, isCollapsed, canCollapse, canRemove, body, actions } = model.useState();
const styles = useStyles2(getStyles);
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
if (canRemove) {
toolbarActions.push(
<ToolbarButton
icon="times"
variant={'default'}
onClick={model.onRemove}
key="remove-button"
aria-label="Remove scene"
/>
);
}
return (
<div className={styles.row}>
<div className={styles.rowHeader}>
<Stack gap={0}>
<div className={styles.title} role="heading" aria-level={1}>
{title}
</div>
{canCollapse && (
<div className={styles.toggle}>
<Button
size="sm"
icon={isCollapsed ? 'angle-down' : 'angle-up'}
fill="text"
variant="secondary"
aria-label={isCollapsed ? 'Expand scene' : 'Collapse scene'}
onClick={model.onToggle}
/>
</div>
)}
</Stack>
<div className={styles.actions}>{toolbarActions}</div>
</div>
{!isCollapsed && <body.Component model={body} isEditing={isEditing} />}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
row: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
gap: theme.spacing(1),
cursor: 'pointer',
}),
toggle: css({}),
title: css({
fontSize: theme.typography.h5.fontSize,
}),
rowHeader: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(2),
}),
actions: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
justifyContent: 'flex-end',
flexGrow: 1,
}),
});

View File

@@ -1,5 +1,6 @@
import { SceneFlexLayout } from '@grafana/scenes';
import { Scene } from './Scene';
import { SceneFlexLayout } from './layout/SceneFlexLayout';
describe('Scene', () => {
it('Simple scene', () => {

View File

@@ -2,22 +2,11 @@ import React from 'react';
import { PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneObjectBase, SceneComponentProps, SceneState, UrlSyncManager } from '@grafana/scenes';
import { PageToolbar, ToolbarButton } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/Page/Page';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectStatePlain, SceneObject } from '../core/types';
import { UrlSyncManager } from '../services/UrlSyncManager';
interface SceneState extends SceneObjectStatePlain {
title: string;
body: SceneObject;
actions?: SceneObject[];
subMenu?: SceneObject;
isEditing?: boolean;
}
export class Scene extends SceneObjectBase<SceneState> {
public static Component = SceneRenderer;
private urlSyncManager?: UrlSyncManager;
@@ -34,30 +23,6 @@ export class Scene extends SceneObjectBase<SceneState> {
}
}
export class EmbeddedScene extends Scene {
public static Component = EmbeddedSceneRenderer;
}
function EmbeddedSceneRenderer({ model }: SceneComponentProps<Scene>) {
const { body, isEditing, subMenu } = model.useState();
return (
<div
style={{
flexGrow: 1,
display: 'flex',
gap: '8px',
overflow: 'auto',
minHeight: '100%',
flexDirection: 'column',
}}
>
{subMenu && <subMenu.Component model={subMenu} />}
<div style={{ flexGrow: 1, display: 'flex', gap: '8px', overflow: 'auto' }}>
<body.Component model={body} isEditing={isEditing} />
</div>
</div>
);
}
function SceneRenderer({ model }: SceneComponentProps<Scene>) {
const { title, body, actions = [], isEditing, $editor, subMenu } = model.useState();

View File

@@ -1,53 +0,0 @@
import React, { CSSProperties } from 'react';
import { Field, Input } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { sceneGraph } from '../core/sceneGraph';
import { SceneComponentProps, SceneLayoutChildState } from '../core/types';
import { VariableDependencyConfig } from '../variables/VariableDependencyConfig';
export interface SceneCanvasTextState extends SceneLayoutChildState {
text: string;
fontSize?: number;
align?: 'left' | 'center' | 'right';
}
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', key } = model.useState();
const style: CSSProperties = {
fontSize: fontSize,
display: 'flex',
flexGrow: 1,
alignItems: 'center',
padding: 16,
justifyContent: align,
};
return (
<div style={style} data-testid={key}>
{sceneGraph.interpolate(model, 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

@@ -1,18 +0,0 @@
import React from 'react';
import { Icon } from '@grafana/ui';
export function SceneDragHandle({ layoutKey, className }: { layoutKey: string; className?: string }) {
return (
<div
className={`${className} grid-drag-handle-${layoutKey}`}
style={{
width: '20px',
height: '20px',
cursor: 'move',
}}
>
<Icon name="draggabledots" />
</div>
);
}

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { LoadingState, PanelData } from '@grafana/data';
import { SceneDataNode } from '../core/SceneDataNode';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { sceneGraph } from '../core/sceneGraph';
import {
SceneComponentProps,
SceneObject,
SceneObjectStatePlain,
SceneLayoutState,
SceneLayoutChild,
} from '../core/types';
interface RepeatOptions extends SceneObjectStatePlain {
layout: SceneObject<SceneLayoutState>;
}
export class ScenePanelRepeater extends SceneObjectBase<RepeatOptions> {
public activate(): void {
super.activate();
this._subs.add(
sceneGraph.getData(this).subscribeToState({
next: (data) => {
if (data.data?.state === LoadingState.Done) {
this.performRepeat(data.data);
}
},
})
);
}
private performRepeat(data: PanelData) {
// assume parent is a layout
const firstChild = this.state.layout.state.children[0]!;
const newChildren: SceneLayoutChild[] = [];
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 });
}
public static Component = ({ model, isEditing }: SceneComponentProps<ScenePanelRepeater>) => {
const { layout } = model.useState();
return <layout.Component model={layout} isEditing={isEditing} />;
};
}

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneLayoutState, SceneComponentProps } from '../core/types';
interface SceneSubMenuState extends SceneLayoutState {}
export class SceneSubMenu extends SceneObjectBase<SceneSubMenuState> {
public static Component = SceneSubMenuRenderer;
}
function SceneSubMenuRenderer({ model }: SceneComponentProps<SceneSubMenu>) {
const { children } = model.useState();
return (
<div style={{ display: 'flex', gap: '16px' }}>
{children.map((child) => (
<child.Component key={child.state.key} model={child} />
))}
</div>
);
}

View File

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

View File

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

View File

@@ -1,136 +0,0 @@
import React from 'react';
import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { VizPanel } from './VizPanel';
let pluginToLoad: PanelPlugin | undefined;
jest.mock('app/features/plugins/importPanelPlugin', () => ({
syncGetPanelPlugin: jest.fn(() => pluginToLoad),
}));
interface OptionsPlugin1 {
showThresholds: boolean;
option2?: string;
}
interface FieldConfigPlugin1 {
customProp?: boolean;
customProp2?: boolean;
junkProp?: boolean;
}
function getTestPlugin1() {
const pluginToLoad = getPanelPlugin(
{
id: 'custom-plugin-id',
},
() => <div>My custom panel</div>
);
pluginToLoad.meta.info.version = '1.0.0';
pluginToLoad.setPanelOptions((builder) => {
builder.addBooleanSwitch({
name: 'Show thresholds',
path: 'showThresholds',
defaultValue: true,
});
builder.addTextInput({
name: 'option2',
path: 'option2',
defaultValue: undefined,
});
});
pluginToLoad.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Unit]: {
defaultValue: 'flop',
},
[FieldConfigProperty.Decimals]: {
defaultValue: 2,
},
},
useCustomConfig: (builder) => {
builder.addBooleanSwitch({
name: 'CustomProp',
path: 'customProp',
defaultValue: false,
});
builder.addBooleanSwitch({
name: 'customProp2',
path: 'customProp2',
defaultValue: false,
});
},
});
pluginToLoad.setMigrationHandler((panel) => {
if (panel.fieldConfig.defaults.custom) {
panel.fieldConfig.defaults.custom.customProp2 = true;
}
return { option2: 'hello' };
});
return pluginToLoad;
}
describe('VizPanel', () => {
describe('when activated', () => {
let panel: VizPanel<OptionsPlugin1, FieldConfigPlugin1>;
beforeAll(async () => {
panel = new VizPanel<OptionsPlugin1, FieldConfigPlugin1>({
pluginId: 'custom-plugin-id',
fieldConfig: {
defaults: { custom: { junkProp: true } },
overrides: [],
},
});
pluginToLoad = getTestPlugin1();
panel.activate();
});
it('load plugin', () => {
expect(panel.getPlugin()).toBe(pluginToLoad);
});
it('should call panel migration handler', () => {
expect(panel.state.options.option2).toEqual('hello');
expect(panel.state.fieldConfig.defaults.custom?.customProp2).toEqual(true);
});
it('should apply option defaults', () => {
expect(panel.state.options.showThresholds).toEqual(true);
});
it('should apply fieldConfig defaults', () => {
expect(panel.state.fieldConfig.defaults.unit).toBe('flop');
expect(panel.state.fieldConfig.defaults.custom!.customProp).toBe(false);
});
it('should should remove props that are not defined for plugin', () => {
expect(panel.state.fieldConfig.defaults.custom?.junkProp).toEqual(undefined);
});
});
describe('When calling on onPanelMigration', () => {
const onPanelMigration = jest.fn();
let panel: VizPanel<OptionsPlugin1, FieldConfigPlugin1>;
beforeAll(async () => {
panel = new VizPanel<OptionsPlugin1, FieldConfigPlugin1>({ pluginId: 'custom-plugin-id' });
pluginToLoad = getTestPlugin1();
pluginToLoad.onPanelMigration = onPanelMigration;
panel.activate();
});
it('should call onPanelMigration with pluginVersion set to initial state (undefined)', () => {
expect(onPanelMigration.mock.calls[0][0].pluginVersion).toBe(undefined);
});
});
});

View File

@@ -1,133 +0,0 @@
import { DeepPartial } from '@reduxjs/toolkit';
import React from 'react';
import {
AbsoluteTimeRange,
FieldConfigSource,
PanelModel,
PanelPlugin,
toUtc,
getPanelOptionsWithDefaults,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { Field, Input } from '@grafana/ui';
import { importPanelPlugin, syncGetPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps, SceneLayoutChildState } from '../../core/types';
import { VariableDependencyConfig } from '../../variables/VariableDependencyConfig';
import { VizPanelRenderer } from './VizPanelRenderer';
export interface VizPanelState<TOptions = {}, TFieldConfig = {}> extends SceneLayoutChildState {
title: string;
pluginId: string;
options: DeepPartial<TOptions>;
fieldConfig: FieldConfigSource<DeepPartial<TFieldConfig>>;
pluginVersion?: string;
// internal state
pluginLoadError?: string;
}
export class VizPanel<TOptions = {}, TFieldConfig = {}> extends SceneObjectBase<VizPanelState<TOptions, TFieldConfig>> {
public static Component = VizPanelRenderer;
public static Editor = VizPanelEditor;
protected _variableDependency = new VariableDependencyConfig(this, { statePaths: ['options', 'title'] });
// Not part of state as this is not serializable
private _plugin?: PanelPlugin;
public constructor(state: Partial<VizPanelState<TOptions, TFieldConfig>>) {
super({
options: {},
fieldConfig: { defaults: {}, overrides: [] },
title: 'Title',
pluginId: 'timeseries',
...state,
});
}
public activate() {
super.activate();
const plugin = syncGetPanelPlugin(this.state.pluginId);
if (plugin) {
this.pluginLoaded(plugin);
} else {
importPanelPlugin(this.state.pluginId)
.then((result) => this.pluginLoaded(result))
.catch((err: Error) => {
this.setState({ pluginLoadError: err.message });
});
}
}
private pluginLoaded(plugin: PanelPlugin) {
const { options, fieldConfig, title, pluginId, pluginVersion } = this.state;
const panel: PanelModel = { title, options, fieldConfig, id: 1, type: pluginId, pluginVersion: pluginVersion };
const currentVersion = this.getPluginVersion(plugin);
if (plugin.onPanelMigration) {
if (currentVersion !== this.state.pluginVersion) {
// These migration handlers also mutate panel.fieldConfig to migrate fieldConfig
panel.options = plugin.onPanelMigration(panel);
}
}
const withDefaults = getPanelOptionsWithDefaults({
plugin,
currentOptions: panel.options,
currentFieldConfig: panel.fieldConfig,
isAfterPluginChange: false,
});
this._plugin = plugin;
this.setState({
options: withDefaults.options,
fieldConfig: withDefaults.fieldConfig,
pluginVersion: currentVersion,
});
}
private getPluginVersion(plugin: PanelPlugin): string {
return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version;
}
public getPlugin(): PanelPlugin | undefined {
return this._plugin;
}
public onChangeTimeRange = (timeRange: AbsoluteTimeRange) => {
const sceneTimeRange = sceneGraph.getTimeRange(this);
sceneTimeRange.onTimeRangeChange({
raw: {
from: toUtc(timeRange.from),
to: toUtc(timeRange.to),
},
from: toUtc(timeRange.from),
to: toUtc(timeRange.to),
});
};
public onOptionsChange = (options: TOptions) => {
this.setState({ options });
};
public onFieldConfigChange = (fieldConfig: FieldConfigSource<TFieldConfig>) => {
this.setState({ fieldConfig });
};
}
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

@@ -1,101 +0,0 @@
import React, { RefCallback, useMemo } from 'react';
import { useMeasure } from 'react-use';
import { PluginContextProvider, useFieldOverrides } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { PanelChrome, ErrorBoundaryAlert, useTheme2 } from '@grafana/ui';
import { appEvents } from 'app/core/core';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps } from '../../core/types';
import { SceneQueryRunner } from '../../querying/SceneQueryRunner';
import { CustomFormatterFn } from '../../variables/interpolation/sceneInterpolator';
import { SceneDragHandle } from '../SceneDragHandle';
import { VizPanel } from './VizPanel';
export function VizPanelRenderer({ model }: SceneComponentProps<VizPanel>) {
const { title, options, fieldConfig, pluginId, pluginLoadError, $data, placement } = model.useState();
const theme = useTheme2();
const replace = useMemo(() => getTemplateSrv().replace, []);
const [ref, { width, height }] = useMeasure();
const plugin = model.getPlugin();
const { data } = sceneGraph.getData(model).useState();
const parentLayout = sceneGraph.getLayout(model);
// TODO: this should probably be parentLayout.isDraggingEnabled() ? placement?.isDraggable : false
// The current logic is not correct, just because parent layout itself is not draggable does not mean children are not
const isDraggable = parentLayout.state.placement?.isDraggable ? placement?.isDraggable : false;
const dragHandle = <SceneDragHandle layoutKey={parentLayout.state.key!} />;
const titleInterpolated = sceneGraph.interpolate(model, title);
// Not sure we need to subscribe to this state
const timeZone = sceneGraph.getTimeRange(model).state.timeZone;
const dataWithOverrides = useFieldOverrides(plugin, fieldConfig, data, timeZone, theme, replace);
if (pluginLoadError) {
return <div>Failed to load plugin: {pluginLoadError}</div>;
}
if (!plugin || !plugin.hasPluginId(pluginId)) {
return <div>Loading plugin panel...</div>;
}
if (!plugin.panel) {
return <div>Panel plugin has no panel component</div>;
}
const PanelComponent = plugin.panel;
// Query runner needs to with for auto maxDataPoints
if ($data instanceof SceneQueryRunner) {
$data.setContainerWidth(width);
}
return (
<div ref={ref as RefCallback<HTMLDivElement>} style={{ position: 'absolute', width: '100%', height: '100%' }}>
<PanelChrome
title={titleInterpolated}
width={width}
height={height}
leftItems={isDraggable ? [dragHandle] : undefined}
>
{(innerWidth, innerHeight) => (
<>
{!dataWithOverrides && <div>No data...</div>}
{dataWithOverrides && (
<ErrorBoundaryAlert dependencies={[plugin, data]}>
<PluginContextProvider meta={plugin.meta}>
<PanelComponent
id={1}
data={dataWithOverrides}
title={title}
timeRange={dataWithOverrides.timeRange}
timeZone={timeZone}
options={options}
fieldConfig={fieldConfig}
transparent={false}
width={innerWidth}
height={innerHeight}
renderCounter={0}
replaceVariables={(str, scopedVars, format) =>
sceneGraph.interpolate(model, str, scopedVars, format as string | CustomFormatterFn | undefined)
}
onOptionsChange={model.onOptionsChange}
onFieldConfigChange={model.onFieldConfigChange}
onChangeTimeRange={model.onChangeTimeRange}
eventBus={appEvents}
/>
</PluginContextProvider>
</ErrorBoundaryAlert>
)}
</>
)}
</PanelChrome>
</div>
);
}
VizPanelRenderer.displayName = 'ScenePanelRenderer';

View File

@@ -1,11 +0,0 @@
export { VizPanel } from './VizPanel/VizPanel';
export { NestedScene } from './NestedScene';
export { Scene } from './Scene';
export { SceneCanvasText } from './SceneCanvasText';
export { SceneToolbarButton, SceneToolbarInput } from './SceneToolbarButton';
export { SceneTimePicker } from './SceneTimePicker';
export { ScenePanelRepeater } from './ScenePanelRepeater';
export { SceneSubMenu } from './SceneSubMenu';
export { SceneFlexLayout } from './layout/SceneFlexLayout';
export { SceneGridLayout } from './layout/SceneGridLayout';
export { SceneGridRow } from './layout/SceneGridRow';

View File

@@ -1,111 +0,0 @@
import React, { CSSProperties } from 'react';
import { Field, RadioButtonGroup } from '@grafana/ui';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneComponentProps, SceneLayoutChild, SceneLayoutState, SceneLayoutChildOptions } from '../../core/types';
export type FlexLayoutDirection = 'column' | 'row';
interface SceneFlexLayoutState extends SceneLayoutState {
direction?: FlexLayoutDirection;
}
export class SceneFlexLayout extends SceneObjectBase<SceneFlexLayoutState> {
public static Component = FlexLayoutRenderer;
public static Editor = FlexLayoutEditor;
public 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: SceneLayoutChild;
direction: FlexLayoutDirection;
isEditing?: boolean;
}) {
const { placement } = item.useState();
return (
<div style={getItemStyles(direction, placement)}>
<item.Component model={item} isEditing={isEditing} />
</div>
);
}
function getItemStyles(direction: FlexLayoutDirection, layout: SceneLayoutChildOptions = {}) {
const { xSizing = 'fill', ySizing = 'fill' } = layout;
const style: CSSProperties = {
display: 'flex',
flexDirection: direction,
minWidth: layout.minWidth,
minHeight: layout.minHeight,
position: 'relative',
};
if (direction === 'column') {
if (layout.height) {
style.height = layout.height;
} else {
style.flexGrow = ySizing === 'fill' ? 1 : 0;
}
if (layout.width) {
style.width = layout.width;
} else {
style.alignSelf = xSizing === 'fill' ? 'stretch' : 'flex-start';
}
} else {
if (layout.height) {
style.height = layout.height;
} else {
style.alignSelf = ySizing === 'fill' ? 'stretch' : 'flex-start';
}
if (layout.width) {
style.width = layout.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

@@ -1,246 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { configureStore } from '../../../../store/configureStore';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneComponentProps, SceneLayoutChildState } from '../../core/types';
import { Scene } from '../Scene';
import { SceneGridLayout } from './SceneGridLayout';
import { SceneGridRow } from './SceneGridRow';
// Mocking AutoSizer to allow testing of the SceneGridLayout component rendering
jest.mock(
'react-virtualized-auto-sizer',
() =>
({ children }: { children: (args: { width: number; height: number }) => React.ReactNode }) =>
children({ height: 600, width: 600 })
);
class TestObject extends SceneObjectBase<SceneLayoutChildState> {
public static Component = (m: SceneComponentProps<TestObject>) => {
return <div data-testid="test-object">TestObject</div>;
};
}
function renderWithProvider(element: JSX.Element) {
const store = configureStore();
return render(<Provider store={store}>{element}</Provider>);
}
describe('SceneGridLayout', () => {
describe('rendering', () => {
it('should render all grid children', async () => {
const scene = new Scene({
title: 'Grid test',
body: new SceneGridLayout({
children: [
new TestObject({ placement: { x: 0, y: 0, width: 12, height: 5 } }),
new TestObject({ placement: { x: 0, y: 5, width: 12, height: 5 } }),
],
}),
});
renderWithProvider(<scene.Component model={scene} />);
expect(screen.queryAllByTestId('test-object')).toHaveLength(2);
});
it('should not render children of a collapsed row', async () => {
const scene = new Scene({
title: 'Grid test',
body: new SceneGridLayout({
children: [
new TestObject({ key: 'a', placement: { x: 0, y: 0, width: 12, height: 5 } }),
new TestObject({ key: 'b', placement: { x: 0, y: 5, width: 12, height: 5 } }),
new SceneGridRow({
title: 'Row A',
key: 'Row A',
isCollapsed: true,
placement: { y: 10 },
children: [new TestObject({ key: 'c', placement: { x: 0, y: 11, width: 12, height: 5 } })],
}),
],
}),
});
renderWithProvider(<scene.Component model={scene} />);
expect(screen.queryAllByTestId('test-object')).toHaveLength(2);
});
it('should render children of an expanded row', async () => {
const scene = new Scene({
title: 'Grid test',
body: new SceneGridLayout({
children: [
new TestObject({ key: 'a', placement: { x: 0, y: 0, width: 12, height: 5 } }),
new TestObject({ key: 'b', placement: { x: 0, y: 5, width: 12, height: 5 } }),
new SceneGridRow({
title: 'Row A',
key: 'Row A',
isCollapsed: false,
placement: { y: 10 },
children: [new TestObject({ key: 'c', placement: { x: 0, y: 11, width: 12, height: 5 } })],
}),
],
}),
});
renderWithProvider(<scene.Component model={scene} />);
expect(screen.queryAllByTestId('test-object')).toHaveLength(3);
});
});
describe('when moving a panel', () => {
it('shoud update layout children placement and order ', () => {
const layout = new SceneGridLayout({
children: [
new TestObject({ key: 'a', placement: { x: 0, y: 0, width: 1, height: 1 } }),
new TestObject({ key: 'b', placement: { x: 1, y: 0, width: 1, height: 1 } }),
new TestObject({ key: 'c', placement: { x: 0, y: 1, width: 1, height: 1 } }),
],
});
layout.onDragStop(
[
{ i: 'b', x: 0, y: 0, w: 1, h: 1 },
{
i: 'a',
x: 0,
y: 1,
w: 1,
h: 1,
},
{
i: 'c',
x: 0,
y: 2,
w: 1,
h: 1,
},
],
// @ts-expect-error
{},
{ i: 'b', x: 0, y: 0, w: 1, h: 1 },
{},
{},
{}
);
expect(layout.state.children[0].state.key).toEqual('b');
expect(layout.state.children[0].state.placement).toEqual({ x: 0, y: 0, width: 1, height: 1 });
expect(layout.state.children[1].state.key).toEqual('a');
expect(layout.state.children[1].state.placement).toEqual({ x: 0, y: 1, width: 1, height: 1 });
expect(layout.state.children[2].state.key).toEqual('c');
expect(layout.state.children[2].state.placement).toEqual({ x: 0, y: 2, width: 1, height: 1 });
});
});
describe('when using rows', () => {
it('should update objects relations when moving object out of a row', () => {
const rowAChild1 = new TestObject({ key: 'row-a-child1', placement: { x: 0, y: 1, width: 1, height: 1 } });
const rowAChild2 = new TestObject({ key: 'row-a-child2', placement: { x: 1, y: 1, width: 1, height: 1 } });
const sourceRow = new SceneGridRow({
title: 'Row A',
key: 'row-a',
children: [rowAChild1, rowAChild2],
placement: { y: 0 },
});
const layout = new SceneGridLayout({
children: [sourceRow],
});
const updatedLayout = layout.moveChildTo(rowAChild1, layout);
expect(updatedLayout.length).toEqual(2);
// the source row should be cloned and with children updated
expect(updatedLayout[0].state.key).toEqual(sourceRow.state.key);
expect(updatedLayout[0]).not.toEqual(sourceRow);
expect((updatedLayout[0] as SceneGridRow).state.children.length).toEqual(1);
expect((updatedLayout[0] as SceneGridRow).state.children).not.toContain(rowAChild1);
// the moved child should be cloned in the root
expect(updatedLayout[1].state.key).toEqual(rowAChild1.state.key);
expect(updatedLayout[1]).not.toEqual(rowAChild1);
});
it('should update objects relations when moving objects between rows', () => {
const rowAChild1 = new TestObject({ key: 'row-a-child1', placement: { x: 0, y: 0, width: 1, height: 1 } });
const rowAChild2 = new TestObject({ key: 'row-a-child2', placement: { x: 1, y: 0, width: 1, height: 1 } });
const sourceRow = new SceneGridRow({
title: 'Row A',
key: 'row-a',
children: [rowAChild1, rowAChild2],
});
const targetRow = new SceneGridRow({
title: 'Row B',
key: 'row-b',
children: [],
});
const panelOutsideARow = new TestObject({ key: 'a', placement: { x: 0, y: 0, width: 1, height: 1 } });
const layout = new SceneGridLayout({
children: [panelOutsideARow, sourceRow, targetRow],
});
const updatedLayout = layout.moveChildTo(rowAChild1, targetRow);
expect(updatedLayout[0]).toEqual(panelOutsideARow);
// the source row should be cloned and with children updated
expect(updatedLayout[1].state.key).toEqual(sourceRow.state.key);
expect(updatedLayout[1]).not.toEqual(sourceRow);
expect((updatedLayout[1] as SceneGridRow).state.children.length).toEqual(1);
// the target row should be cloned and with children updated
expect(updatedLayout[2].state.key).toEqual(targetRow.state.key);
expect(updatedLayout[2]).not.toEqual(targetRow);
expect((updatedLayout[2] as SceneGridRow).state.children.length).toEqual(1);
// the moved object should be cloned and added to the target row
const movedObject = (updatedLayout[2] as SceneGridRow).state.children[0];
expect(movedObject.state.key).toEqual('row-a-child1');
expect(movedObject).not.toEqual(rowAChild1);
});
it('should update position of objects when row is expanded', () => {
const rowAChild1 = new TestObject({ key: 'row-a-child1', placement: { x: 0, y: 1, width: 1, height: 1 } });
const rowAChild2 = new TestObject({ key: 'row-a-child2', placement: { x: 1, y: 1, width: 1, height: 1 } });
const rowA = new SceneGridRow({
title: 'Row A',
key: 'row-a',
children: [rowAChild1, rowAChild2],
placement: { y: 0 },
isCollapsed: true,
});
const panelOutsideARow = new TestObject({ key: 'outsider', placement: { x: 0, y: 1, width: 1, height: 1 } });
const rowBChild1 = new TestObject({ key: 'row-b-child1', placement: { x: 0, y: 3, width: 1, height: 1 } });
const rowB = new SceneGridRow({
title: 'Row B',
key: 'row-b',
children: [rowBChild1],
placement: { y: 2 },
isCollapsed: false,
});
const layout = new SceneGridLayout({
children: [rowA, panelOutsideARow, rowB],
});
layout.toggleRow(rowA);
expect(panelOutsideARow.state!.placement!.y).toEqual(2);
expect(rowB.state!.placement!.y).toEqual(3);
expect(rowBChild1.state!.placement!.y).toEqual(4);
});
});
});

View File

@@ -1,392 +0,0 @@
import React from 'react';
import ReactGridLayout from 'react-grid-layout';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DEFAULT_PANEL_SPAN, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneComponentProps, SceneLayoutChild, SceneLayoutState, SceneLayoutChildOptions } from '../../core/types';
import { SceneGridRow } from './SceneGridRow';
interface SceneGridLayoutState extends SceneLayoutState {}
export class SceneGridLayout extends SceneObjectBase<SceneGridLayoutState> {
public static Component = SceneGridLayoutRenderer;
private _skipOnLayoutChange = false;
public constructor(state: SceneGridLayoutState) {
super({
...state,
placement: {
isDraggable: true,
...state.placement,
},
children: sortChildrenByPosition(state.children),
});
}
public toggleRow(row: SceneGridRow) {
const isCollapsed = row.state.isCollapsed;
if (!isCollapsed) {
row.setState({ isCollapsed: true });
// To force re-render
this.setState({});
return;
}
const rowChildren = row.state.children;
if (rowChildren.length === 0) {
row.setState({ isCollapsed: false });
this.setState({});
return;
}
// Ok we are expanding row. We need to update row children y pos (incase they are incorrect) and push items below down
// Code copied from DashboardModel toggleRow()
const rowY = row.state.placement?.y!;
const firstPanelYPos = rowChildren[0].state.placement?.y ?? rowY;
const yDiff = firstPanelYPos - (rowY + 1);
// y max will represent the bottom y pos after all panels have been added
// needed to know home much panels below should be pushed down
let yMax = rowY;
for (const panel of rowChildren) {
// set the y gridPos if it wasn't already set
const newSize = { ...panel.state.placement };
newSize.y = newSize.y ?? rowY;
// make sure y is adjusted (in case row moved while collapsed)
newSize.y -= yDiff;
if (newSize.y > panel.state.placement?.y!) {
panel.setState({ placement: newSize });
}
// update insert post and y max
yMax = Math.max(yMax, Number(newSize.y!) + Number(newSize.height!));
}
const pushDownAmount = yMax - rowY - 1;
// push panels below down
for (const child of this.state.children) {
if (child.state.placement?.y! > rowY) {
this.pushChildDown(child, pushDownAmount);
}
if (child instanceof SceneGridRow && child !== row) {
for (const rowChild of child.state.children) {
if (rowChild.state.placement?.y! > rowY) {
this.pushChildDown(rowChild, pushDownAmount);
}
}
}
}
row.setState({ isCollapsed: false });
// Trigger re-render
this.setState({});
}
public onLayoutChange = (layout: ReactGridLayout.Layout[]) => {
if (this._skipOnLayoutChange) {
// Layout has been updated by other RTL handler already
this._skipOnLayoutChange = false;
return;
}
for (const item of layout) {
const child = this.getSceneLayoutChild(item.i);
const nextSize = {
x: item.x,
y: item.y,
width: item.w,
height: item.h,
};
if (!isItemSizeEqual(child.state.placement!, nextSize)) {
child.setState({
placement: {
...child.state.placement,
...nextSize,
},
});
}
}
this.setState({ children: sortChildrenByPosition(this.state.children) });
};
/**
* Will also scan row children and return child of the row
*/
public getSceneLayoutChild(key: string) {
for (const child of this.state.children) {
if (child.state.key === key) {
return child;
}
if (child instanceof SceneGridRow) {
for (const rowChild of child.state.children) {
if (rowChild.state.key === key) {
return rowChild;
}
}
}
}
throw new Error('Scene layout child not found for GridItem');
}
public onResizeStop: ReactGridLayout.ItemCallback = (_, o, n) => {
const child = this.getSceneLayoutChild(n.i);
child.setState({
placement: {
...child.state.placement,
width: n.w,
height: n.h,
},
});
};
private pushChildDown(child: SceneLayoutChild, amount: number) {
child.setState({
placement: {
...child.state.placement,
y: child.state.placement?.y! + amount,
},
});
}
/**
* We assume the layout array is storted according to y pos, and walk upwards until we find a row.
* If it is collapsed there is no row to add it to. The default is then to return the SceneGridLayout itself
*/
private findGridItemSceneParent(layout: ReactGridLayout.Layout[], startAt: number): SceneGridRow | SceneGridLayout {
for (let i = startAt; i >= 0; i--) {
const gridItem = layout[i];
const sceneChild = this.getSceneLayoutChild(gridItem.i);
if (sceneChild instanceof SceneGridRow) {
// the closest row is collapsed return null
if (sceneChild.state.isCollapsed) {
return this;
}
return sceneChild;
}
}
return this;
}
/**
* This likely needs a slighltly different approach. Where we clone or deactivate or and re-activate the moved child
*/
public moveChildTo(child: SceneLayoutChild, target: SceneGridLayout | SceneGridRow) {
const currentParent = child.parent!;
let rootChildren = this.state.children;
const newChild = child.clone({ key: child.state.key });
// Remove from current parent row
if (currentParent instanceof SceneGridRow) {
const newRow = currentParent.clone({
children: currentParent.state.children.filter((c) => c.state.key !== child.state.key),
});
// new children with new row
rootChildren = rootChildren.map((c) => (c === currentParent ? newRow : c));
// if target is also a row
if (target instanceof SceneGridRow) {
const targetRow = target.clone({ children: [...target.state.children, newChild] });
rootChildren = rootChildren.map((c) => (c === target ? targetRow : c));
} else {
// target is the main grid
rootChildren = [...rootChildren, newChild];
}
} else {
// current parent is the main grid remove it from there
rootChildren = rootChildren.filter((c) => c.state.key !== child.state.key);
// Clone the target row and add the child
const targetRow = target.clone({ children: [...target.state.children, newChild] });
// Replace row with new row
rootChildren = rootChildren.map((c) => (c === target ? targetRow : c));
}
return rootChildren;
}
public onDragStop: ReactGridLayout.ItemCallback = (gridLayout, o, updatedItem) => {
const sceneChild = this.getSceneLayoutChild(updatedItem.i)!;
// Need to resort the grid layout based on new position (needed to to find the new parent)
gridLayout = sortGridLayout(gridLayout);
// Update children positions if they have changed
for (let i = 0; i < gridLayout.length; i++) {
const gridItem = gridLayout[i];
const child = this.getSceneLayoutChild(gridItem.i)!;
const childSize = child.state.placement!;
if (childSize?.x !== gridItem.x || childSize?.y !== gridItem.y) {
child.setState({
placement: {
...child.state.placement,
x: gridItem.x,
y: gridItem.y,
},
});
}
}
// Update the parent if the child if it has moved to a row or back to the grid
const indexOfUpdatedItem = gridLayout.findIndex((item) => item.i === updatedItem.i);
const newParent = this.findGridItemSceneParent(gridLayout, indexOfUpdatedItem - 1);
let newChildren = this.state.children;
if (newParent !== sceneChild.parent) {
newChildren = this.moveChildTo(sceneChild, newParent);
}
this.setState({ children: sortChildrenByPosition(newChildren) });
this._skipOnLayoutChange = true;
};
private toGridCell(child: SceneLayoutChild): ReactGridLayout.Layout {
const size = child.state.placement!;
let x = size.x ?? 0;
let y = size.y ?? 0;
const w = Number.isInteger(Number(size.width)) ? Number(size.width) : DEFAULT_PANEL_SPAN;
const h = Number.isInteger(Number(size.height)) ? Number(size.height) : DEFAULT_PANEL_SPAN;
let isDraggable = Boolean(child.state.placement?.isDraggable);
let isResizable = Boolean(child.state.placement?.isResizable);
if (child instanceof SceneGridRow) {
isDraggable = child.state.isCollapsed ? true : false;
isResizable = false;
}
return { i: child.state.key!, x, y, h, w, isResizable, isDraggable };
}
public buildGridLayout(width: number): ReactGridLayout.Layout[] {
let cells: ReactGridLayout.Layout[] = [];
for (const child of this.state.children) {
cells.push(this.toGridCell(child));
if (child instanceof SceneGridRow && !child.state.isCollapsed) {
for (const rowChild of child.state.children) {
cells.push(this.toGridCell(rowChild));
}
}
}
// Sort by position
cells = sortGridLayout(cells);
if (width < 768) {
// We should not persist the mobile layout
this._skipOnLayoutChange = true;
return cells.map((cell) => ({ ...cell, w: 24 }));
}
this._skipOnLayoutChange = false;
return cells;
}
}
function SceneGridLayoutRenderer({ model }: SceneComponentProps<SceneGridLayout>) {
const { children } = model.useState();
validateChildrenSize(children);
return (
<AutoSizer disableHeight>
{({ width }) => {
if (width === 0) {
return null;
}
const layout = model.buildGridLayout(width);
return (
/**
* The children is using a width of 100% so we need to guarantee that it is wrapped
* in an element that has the calculated size given by the AutoSizer. The AutoSizer
* has a width of 0 and will let its content overflow its div.
*/
<div style={{ width: `${width}px`, height: '100%' }}>
<ReactGridLayout
width={width}
/*
Disable draggable if mobile device, solving an issue with unintentionally
moving panels. https://github.com/grafana/grafana/issues/18497
theme.breakpoints.md = 769
*/
isDraggable={width > 768}
isResizable={false}
containerPadding={[0, 0]}
useCSSTransforms={false}
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
cols={GRID_COLUMN_COUNT}
rowHeight={GRID_CELL_HEIGHT}
draggableHandle={`.grid-drag-handle-${model.state.key}`}
// @ts-ignore: ignoring for now until we make the size type numbers-only
layout={layout}
onDragStop={model.onDragStop}
onResizeStop={model.onResizeStop}
onLayoutChange={model.onLayoutChange}
isBounded={false}
>
{layout.map((gridItem) => {
const sceneChild = model.getSceneLayoutChild(gridItem.i)!;
return (
<div key={sceneChild.state.key} style={{ display: 'flex' }}>
<sceneChild.Component model={sceneChild} key={sceneChild.state.key} />
</div>
);
})}
</ReactGridLayout>
</div>
);
}}
</AutoSizer>
);
}
function validateChildrenSize(children: SceneLayoutChild[]) {
if (
children.find(
(c) =>
!c.state.placement ||
c.state.placement.height === undefined ||
c.state.placement.width === undefined ||
c.state.placement.x === undefined ||
c.state.placement.y === undefined
)
) {
throw new Error('All children must have a size specified');
}
}
function isItemSizeEqual(a: SceneLayoutChildOptions, b: SceneLayoutChildOptions) {
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
function sortChildrenByPosition(children: SceneLayoutChild[]) {
return [...children].sort((a, b) => {
return a.state.placement?.y! - b.state.placement?.y! || a.state.placement?.x! - b.state.placement?.x!;
});
}
function sortGridLayout(layout: ReactGridLayout.Layout[]) {
return [...layout].sort((a, b) => a.y - b.y || a.x! - b.x);
}

View File

@@ -1,122 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps, SceneLayoutChildState, SceneObject, SceneObjectUrlValues } from '../../core/types';
import { SceneObjectUrlSyncConfig } from '../../services/SceneObjectUrlSyncConfig';
import { SceneDragHandle } from '../SceneDragHandle';
import { SceneGridLayout } from './SceneGridLayout';
export interface SceneGridRowState extends SceneLayoutChildState {
title: string;
isCollapsible?: boolean;
isCollapsed?: boolean;
children: Array<SceneObject<SceneLayoutChildState>>;
}
export class SceneGridRow extends SceneObjectBase<SceneGridRowState> {
public static Component = SceneGridRowRenderer;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['rowc'] });
public constructor(state: SceneGridRowState) {
super({
isCollapsible: true,
...state,
placement: {
isResizable: false,
isDraggable: true,
...state.placement,
x: 0,
height: 1,
width: GRID_COLUMN_COUNT,
},
});
}
public onCollapseToggle = () => {
if (!this.state.isCollapsible) {
return;
}
const layout = this.parent;
if (!layout || !(layout instanceof SceneGridLayout)) {
throw new Error('SceneGridRow must be a child of SceneGridLayout');
}
layout.toggleRow(this);
};
public getUrlState(state: SceneGridRowState) {
return { rowc: state.isCollapsed ? '1' : '0' };
}
public updateFromUrl(values: SceneObjectUrlValues) {
const isCollapsed = values.rowc === '1';
if (isCollapsed !== this.state.isCollapsed) {
this.onCollapseToggle();
}
}
}
export function SceneGridRowRenderer({ model }: SceneComponentProps<SceneGridRow>) {
const styles = useStyles2(getSceneGridRowStyles);
const { isCollapsible, isCollapsed, title, placement } = model.useState();
const layout = sceneGraph.getLayout(model);
const dragHandle = <SceneDragHandle layoutKey={layout.state.key!} />;
return (
<div className={styles.row}>
<div className={cx(styles.rowHeader, isCollapsed && styles.rowHeaderCollapsed)}>
<div onClick={model.onCollapseToggle} className={styles.rowTitleWrapper}>
{isCollapsible && <Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />}
<span className={styles.rowTitle}>{title}</span>
</div>
{placement?.isDraggable && isCollapsed && <div>{dragHandle}</div>}
</div>
</div>
);
}
const getSceneGridRowStyles = (theme: GrafanaTheme2) => {
return {
row: css({
width: '100%',
height: '100%',
position: 'relative',
zIndex: 0,
display: 'flex',
flexDirection: 'column',
}),
rowHeader: css({
width: '100%',
height: '30px',
display: 'flex',
justifyContent: 'space-between',
marginBottom: '8px',
border: `1px solid transparent`,
}),
rowTitleWrapper: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
}),
rowHeaderCollapsed: css({
marginBottom: '0px',
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.borderRadius(1),
}),
rowTitle: css({
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
}),
};
};

View File

@@ -1,58 +0,0 @@
import React, { useEffect } from 'react';
import { SceneComponentProps, SceneEditor, SceneObject } from './types';
export function SceneComponentWrapper<T extends SceneObject>({
model,
isEditing,
...otherProps
}: SceneComponentProps<T>) {
const Component = (model as any).constructor['Component'] ?? EmptyRenderer;
const inner = <Component {...otherProps} model={model} isEditing={isEditing} />;
// Handle component activation state state
useEffect(() => {
if (!model.isActive) {
model.activate();
}
return () => {
if (model.isActive) {
model.deactivate();
}
};
}, [model]);
/** Useful for tests and evaluating efficiency in reducing renderings */
// @ts-ignore
model._renderCount += 1;
if (!isEditing) {
return inner;
}
const editor = getSceneEditor(model);
const EditWrapper = getSceneEditor(model).getEditComponentWrapper();
return (
<EditWrapper model={model} editor={editor}>
{inner}
</EditWrapper>
);
}
function EmptyRenderer<T>(_: SceneComponentProps<T>): React.ReactElement | null {
return null;
}
function getSceneEditor(sceneObject: SceneObject): SceneEditor {
const { $editor } = sceneObject.state;
if ($editor) {
return $editor;
}
if (sceneObject.parent) {
return getSceneEditor(sceneObject.parent);
}
throw new Error('No editor found in scene tree');
}

View File

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

View File

@@ -1,163 +0,0 @@
import { map } from 'rxjs';
import {
ArrayVector,
getDefaultTimeRange,
LoadingState,
standardTransformersRegistry,
toDataFrame,
} from '@grafana/data';
import { SceneFlexLayout } from '../components';
import { SceneDataNode } from './SceneDataNode';
import { SceneDataTransformer } from './SceneDataTransformer';
import { SceneObjectBase } from './SceneObjectBase';
import { sceneGraph } from './sceneGraph';
class TestSceneObject extends SceneObjectBase<{}> {}
describe('SceneDataTransformer', () => {
let transformerSpy1 = jest.fn();
let transformerSpy2 = jest.fn();
beforeEach(() => {
standardTransformersRegistry.setInit(() => {
return [
{
id: 'customTransformer1',
editor: () => null,
transformation: {
id: 'customTransformer1',
name: 'Custom Transformer',
operator: (options) => (source) => {
transformerSpy1(options);
return source.pipe(
map((data) => {
return data.map((frame) => {
return {
...frame,
fields: frame.fields.map((field) => {
return {
...field,
values: new ArrayVector(field.values.toArray().map((v) => v * 2)),
};
}),
};
});
})
);
},
},
name: 'Custom Transformer',
},
{
id: 'customTransformer2',
editor: () => null,
transformation: {
id: 'customTransformer2',
name: 'Custom Transformer2',
operator: (options) => (source) => {
transformerSpy2(options);
return source.pipe(
map((data) => {
return data.map((frame) => {
return {
...frame,
fields: frame.fields.map((field) => {
return {
...field,
values: new ArrayVector(field.values.toArray().map((v) => v * 3)),
};
}),
};
});
})
);
},
},
name: 'Custom Transformer 2',
},
];
});
});
it('applies transformations to closest data node', () => {
const sourceDataNode = new SceneDataNode({
data: {
state: LoadingState.Loading,
timeRange: getDefaultTimeRange(),
series: [
toDataFrame([
[100, 1],
[200, 2],
[300, 3],
]),
],
},
});
const transformationNode = new SceneDataTransformer({
transformations: [
{
id: 'customTransformer1',
options: {
option: 'value1',
},
},
{
id: 'customTransformer2',
options: {
option: 'value2',
},
},
],
});
const consumer = new TestSceneObject({
$data: transformationNode,
});
// @ts-expect-error
const scene = new SceneFlexLayout({
$data: sourceDataNode,
children: [consumer],
});
sourceDataNode.activate();
transformationNode.activate();
// Transforms initial data
let data = sceneGraph.getData(consumer).state.data;
expect(transformerSpy1).toHaveBeenCalledTimes(1);
expect(transformerSpy1).toHaveBeenCalledWith({ option: 'value1' });
expect(transformerSpy2).toHaveBeenCalledTimes(1);
expect(transformerSpy2).toHaveBeenCalledWith({ option: 'value2' });
expect(data?.series.length).toBe(1);
expect(data?.series[0].fields).toHaveLength(2);
expect(data?.series[0].fields[0].values.toArray()).toEqual([600, 1200, 1800]);
expect(data?.series[0].fields[1].values.toArray()).toEqual([6, 12, 18]);
sourceDataNode.setState({
data: {
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
series: [
toDataFrame([
[10, 10],
[20, 20],
[30, 30],
]),
],
},
});
// Transforms updated data
data = sceneGraph.getData(consumer).state.data;
expect(transformerSpy1).toHaveBeenCalledTimes(2);
expect(transformerSpy2).toHaveBeenCalledTimes(2);
expect(data?.series[0].fields[0].values.toArray()).toEqual([60, 120, 180]);
expect(data?.series[0].fields[1].values.toArray()).toEqual([60, 120, 180]);
});
});

View File

@@ -1,66 +0,0 @@
import { Observable, of, Unsubscribable } from 'rxjs';
import { DataTransformerConfig, LoadingState, PanelData } from '@grafana/data';
import { getTransformationsStream } from '../querying/SceneQueryRunner';
import { SceneObjectBase } from './SceneObjectBase';
import { sceneGraph } from './sceneGraph';
import { SceneDataState } from './types';
export interface SceneDataTransformerState extends SceneDataState {
transformations?: DataTransformerConfig[];
}
export class SceneDataTransformer extends SceneObjectBase<SceneDataTransformerState> {
private _transformationsSub?: Unsubscribable;
public activate() {
super.activate();
if (!this.parent || !this.parent.parent) {
return;
}
const initialData = sceneGraph.getData(this.parent.parent).state.data;
if (initialData) {
this.transformData(of(initialData));
}
this._subs.add(
// Need to subscribe to the parent's parent because the parent has a $data reference to this object
sceneGraph.getData(this.parent.parent).subscribeToState({
next: (data) => {
if (data.data?.state === LoadingState.Done) {
this.transformData(of(data.data));
} else {
this.setState({ data: data.data });
}
},
})
);
}
public deactivate(): void {
super.deactivate();
if (this._transformationsSub) {
this._transformationsSub.unsubscribe();
this._transformationsSub = undefined;
}
}
private transformData(data: Observable<PanelData>) {
if (this._transformationsSub) {
this._transformationsSub.unsubscribe();
this._transformationsSub = undefined;
}
this._transformationsSub = data.pipe(getTransformationsStream(this, this.state.transformations)).subscribe({
next: (data) => {
this.setState({ data });
},
});
}
}

View File

@@ -1,145 +0,0 @@
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { SceneDataNode } from './SceneDataNode';
import { SceneObjectBase } from './SceneObjectBase';
import { SceneObjectStateChangedEvent } from './events';
import { SceneLayoutChild, SceneObject, SceneObjectStatePlain } from './types';
interface TestSceneState extends SceneObjectStatePlain {
name?: string;
nested?: SceneObject<TestSceneState>;
children?: SceneLayoutChild[];
actions?: SceneObject[];
}
class TestScene extends SceneObjectBase<TestSceneState> {}
describe('SceneObject', () => {
it('Can clone', () => {
const scene = new TestScene({
nested: new TestScene({
name: 'nested',
}),
actions: [
new TestScene({
name: 'action child',
}),
],
children: [
new TestScene({
name: 'layout child',
}),
],
});
scene.state.nested?.activate();
const clone = scene.clone();
expect(clone).not.toBe(scene);
expect(clone.state.nested).not.toBe(scene.state.nested);
expect(clone.state.nested?.isActive).toBe(false);
expect(clone.state.children![0]).not.toBe(scene.state.children![0]);
expect(clone.state.actions![0]).not.toBe(scene.state.actions![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');
});
it('Cannot modify state', () => {
const scene = new TestScene({ name: 'name' });
expect(() => {
scene.state.name = 'new name';
}).toThrow();
scene.setState({ name: 'new name' });
expect(scene.state.name).toBe('new name');
expect(() => {
scene.state.name = 'other name';
}).toThrow();
});
describe('When activated', () => {
const scene = new TestScene({
$data: new SceneDataNode({}),
$variables: new SceneVariableSet({ variables: [] }),
});
scene.activate();
it('Should set isActive true', () => {
expect(scene.isActive).toBe(true);
});
it('Should activate $data', () => {
expect(scene.state.$data!.isActive).toBe(true);
});
it('Should activate $variables', () => {
expect(scene.state.$variables!.isActive).toBe(true);
});
});
describe('When deactivated', () => {
const scene = new TestScene({
$data: new SceneDataNode({}),
$variables: new SceneVariableSet({ variables: [] }),
});
scene.activate();
// Subscribe to state change and to event
const stateSub = scene.subscribeToState({ next: () => {} });
const eventSub = scene.subscribeToEvent(SceneObjectStateChangedEvent, () => {});
scene.deactivate();
it('Should close subscriptions', () => {
expect(stateSub.closed).toBe(true);
expect((eventSub as any).closed).toBe(true);
});
it('Should set isActive false', () => {
expect(scene.isActive).toBe(false);
});
it('Should deactivate $data', () => {
expect(scene.state.$data!.isActive).toBe(false);
});
it('Should deactivate $variables', () => {
expect(scene.state.$variables!.isActive).toBe(false);
});
});
});

View File

@@ -1,213 +0,0 @@
import { useEffect } from 'react';
import { Observer, Subject, Subscription, Unsubscribable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { BusEvent, BusEventHandler, BusEventType, EventBusSrv } from '@grafana/data';
import { useForceUpdate } from '@grafana/ui';
import { SceneVariableDependencyConfigLike } from '../variables/types';
import { SceneComponentWrapper } from './SceneComponentWrapper';
import { SceneObjectStateChangedEvent } from './events';
import { SceneObject, SceneComponent, SceneObjectState, SceneObjectUrlSyncHandler } from './types';
import { cloneSceneObject, forEachSceneObjectInState } from './utils';
export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObjectState>
implements SceneObject<TState>
{
private _isActive = false;
private _subject = new Subject<TState>();
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;
protected _urlSync: SceneObjectUrlSyncHandler<TState> | undefined;
public constructor(state: TState) {
if (!state.key) {
state.key = uuidv4();
}
this._state = Object.freeze(state);
this._subject.next(state);
this.setParent();
}
/** Current state */
public get state(): TState {
return this._state;
}
/** True if currently being active (ie displayed for visual objects) */
public get isActive(): boolean {
return this._isActive;
}
/** Returns the parent, undefined for root object */
public get parent(): SceneObject | undefined {
return this._parent;
}
/** Returns variable dependency config */
public get variableDependency(): SceneVariableDependencyConfigLike | undefined {
return this._variableDependency;
}
/** Returns url sync config */
public get urlSync(): SceneObjectUrlSyncHandler<TState> | undefined {
return this._urlSync;
}
/**
* Used in render functions when rendering a SceneObject.
* Wraps the component in an EditWrapper that handles edit mode
*/
public get Component(): SceneComponent<this> {
return SceneComponentWrapper;
}
/**
* Temporary solution, should be replaced by declarative options
*/
public get Editor(): SceneComponent<this> {
return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent<this>;
}
private setParent() {
forEachSceneObjectInState(this._state, (child) => (child._parent = this));
}
/**
* Subscribe to the scene state subject
**/
public subscribeToState(observerOrNext?: Partial<Observer<TState>>): Subscription {
return this._subject.subscribe(observerOrNext);
}
/**
* Subscribe to the scene event
**/
public subscribeToEvent<T extends BusEvent>(eventType: BusEventType<T>, handler: BusEventHandler<T>): Unsubscribable {
return this._events.subscribe(eventType, handler);
}
public setState(update: Partial<TState>) {
const prevState = this._state;
const newState: TState = {
...this._state,
...update,
};
this._state = Object.freeze(newState);
this.setParent();
this._subject.next(newState);
// Bubble state change event. This is event is subscribed to by UrlSyncManager and UndoManager
this.publishEvent(
new SceneObjectStateChangedEvent({
prevState,
newState,
partialUpdate: update,
changedObject: this,
}),
true
);
}
/*
* Publish an event and optionally bubble it up the scene
**/
public publishEvent(event: BusEvent, bubble?: boolean) {
this._events.publish(event);
if (bubble && this.parent) {
this.parent.publishEvent(event, bubble);
}
}
public getRoot(): SceneObject {
return !this._parent ? this : this._parent.getRoot();
}
/**
* Called by the SceneComponentWrapper when the react component is mounted
*/
public activate() {
this._isActive = true;
const { $data, $variables } = this.state;
if ($data && !$data.isActive) {
$data.activate();
}
if ($variables && !$variables.isActive) {
$variables.activate();
}
}
/**
* Called by the SceneComponentWrapper when the react component is unmounted
*/
public deactivate(): void {
this._isActive = false;
const { $data, $variables } = this.state;
if ($data && $data.isActive) {
$data.deactivate();
}
if ($variables && $variables.isActive) {
$variables.deactivate();
}
// Clear subscriptions and listeners
this._events.removeAllListeners();
this._subs.unsubscribe();
this._subs = new Subscription();
this._subject.complete();
this._subject = new Subject<TState>();
}
/**
* Utility hook to get and subscribe to state
*/
public useState() {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSceneObjectState(this);
}
/** Force a re-render, should only be needed when variable values change */
public forceRender(): void {
this.setState({});
}
/**
* Will create new SceneObject with shallow-cloned state, but all state items of type SceneObject are deep cloned
*/
public clone(withState?: Partial<TState>): this {
return cloneSceneObject(this, withState);
}
}
/**
* This hook is always returning model.state instead of a useState that remembers the last state emitted on the subject
* The reason for this is so that if the model instance change this function will always return the latest state.
*/
function useSceneObjectState<TState extends SceneObjectState>(model: SceneObjectBase<TState>): TState {
const forceUpdate = useForceUpdate();
useEffect(() => {
const s = model.subscribeToState({ next: forceUpdate });
return () => s.unsubscribe();
}, [model, forceUpdate]);
return model.state;
}

View File

@@ -1,36 +0,0 @@
import { SceneTimeRange } from './SceneTimeRange';
describe('SceneTimeRange', () => {
it('when created should evaluate time range', () => {
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' });
expect(timeRange.state.value.raw.from).toBe('now-1h');
});
it('when time range refreshed should evaluate and update value', async () => {
const timeRange = new SceneTimeRange({ from: 'now-30s', to: 'now' });
const startTime = timeRange.state.value.from.valueOf();
await new Promise((r) => setTimeout(r, 2));
timeRange.onRefresh();
const diff = timeRange.state.value.from.valueOf() - startTime;
expect(diff).toBeGreaterThan(1);
});
it('toUrlValues with relative range', () => {
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' });
expect(timeRange.urlSync?.getUrlState(timeRange.state)).toEqual({
from: 'now-1h',
to: 'now',
});
});
it('updateFromUrl with ISO time', () => {
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' });
timeRange.urlSync?.updateFromUrl({
from: '2021-01-01T10:00:00.000Z',
to: '2021-02-03T01:20:00.000Z',
});
expect(timeRange.state.from).toEqual('2021-01-01T10:00:00.000Z');
expect(timeRange.state.value.from.valueOf()).toEqual(1609495200000);
});
});

View File

@@ -1,107 +0,0 @@
import { dateMath, getTimeZone, TimeRange, TimeZone, toUtc } from '@grafana/data';
import { SceneObjectUrlSyncConfig } from '../services/SceneObjectUrlSyncConfig';
import { SceneObjectBase } from './SceneObjectBase';
import { SceneTimeRangeLike, SceneTimeRangeState, SceneObjectUrlValues, SceneObjectUrlValue } from './types';
export class SceneTimeRange extends SceneObjectBase<SceneTimeRangeState> implements SceneTimeRangeLike {
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to'] });
public constructor(state: Partial<SceneTimeRangeState> = {}) {
const from = state.from ?? 'now-6h';
const to = state.to ?? 'now';
const timeZone = state.timeZone ?? getTimeZone();
const value = evaluateTimeRange(from, to, timeZone);
super({ from, to, timeZone, value, ...state });
}
public onTimeRangeChange = (timeRange: TimeRange) => {
const update: Partial<SceneTimeRangeState> = {};
if (typeof timeRange.raw.from === 'string') {
update.from = timeRange.raw.from;
} else {
update.from = timeRange.raw.from.toISOString();
}
if (typeof timeRange.raw.to === 'string') {
update.to = timeRange.raw.to;
} else {
update.to = timeRange.raw.to.toISOString();
}
update.value = evaluateTimeRange(update.from, update.to, this.state.timeZone);
this.setState(update);
};
public onRefresh = () => {
this.setState({ value: evaluateTimeRange(this.state.from, this.state.to, this.state.timeZone) });
};
public onIntervalChanged = (_: string) => {};
public getUrlState(state: SceneTimeRangeState) {
return { from: state.from, to: state.to };
}
public updateFromUrl(values: SceneObjectUrlValues) {
const update: Partial<SceneTimeRangeState> = {};
const from = parseUrlParam(values.from);
if (from) {
update.from = from;
}
const to = parseUrlParam(values.to);
if (to) {
update.to = to;
}
update.value = evaluateTimeRange(update.from ?? this.state.from, update.to ?? this.state.to, this.state.timeZone);
this.setState(update);
}
}
function parseUrlParam(value: SceneObjectUrlValue): string | null {
if (typeof value !== 'string') {
return null;
}
if (value.indexOf('now') !== -1) {
return value;
}
if (value.length === 8) {
const utcValue = toUtc(value, 'YYYYMMDD');
if (utcValue.isValid()) {
return utcValue.toISOString();
}
} else if (value.length === 15) {
const utcValue = toUtc(value, 'YYYYMMDDTHHmmss');
if (utcValue.isValid()) {
return utcValue.toISOString();
}
} else if (value.length === 24) {
const utcValue = toUtc(value);
return utcValue.toISOString();
}
const epoch = parseInt(value, 10);
if (!isNaN(epoch)) {
return toUtc(epoch).toISOString();
}
return null;
}
function evaluateTimeRange(from: string, to: string, timeZone: TimeZone, fiscalYearStartMonth?: number): TimeRange {
return {
from: dateMath.parse(from, false, timeZone, fiscalYearStartMonth)!,
to: dateMath.parse(to, true, timeZone, fiscalYearStartMonth)!,
raw: {
from: from,
to: to,
},
};
}

View File

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

View File

@@ -1,125 +0,0 @@
import { getDefaultTimeRange, LoadingState, ScopedVars } from '@grafana/data';
import { CustomFormatterFn, sceneInterpolator } from '../variables/interpolation/sceneInterpolator';
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { SceneVariables } from '../variables/types';
import { SceneDataNode } from './SceneDataNode';
import { SceneTimeRange as SceneTimeRangeImpl } from './SceneTimeRange';
import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRangeLike } from './types';
/**
* Get the closest node with variables
*/
export function getVariables(sceneObject: SceneObject): SceneVariables {
if (sceneObject.state.$variables) {
return sceneObject.state.$variables;
}
if (sceneObject.parent) {
return getVariables(sceneObject.parent);
}
return EmptyVariableSet;
}
/**
* Will walk up the scene object graph to the closest $data scene object
*/
export function getData(sceneObject: SceneObject): SceneObject<SceneDataState> {
const { $data } = sceneObject.state;
if ($data) {
return $data;
}
if (sceneObject.parent) {
return getData(sceneObject.parent);
}
return EmptyDataNode;
}
/**
* Will walk up the scene object graph to the closest $timeRange scene object
*/
export function getTimeRange(sceneObject: SceneObject): SceneTimeRangeLike {
const { $timeRange } = sceneObject.state;
if ($timeRange) {
return $timeRange;
}
if (sceneObject.parent) {
return getTimeRange(sceneObject.parent);
}
return DefaultTimeRange;
}
/**
* Will walk up the scene object graph to the closest $editor scene object
*/
export function getSceneEditor(sceneObject: SceneObject): SceneEditor {
const { $editor } = sceneObject.state;
if ($editor) {
return $editor;
}
if (sceneObject.parent) {
return getSceneEditor(sceneObject.parent);
}
throw new Error('No editor found in scene tree');
}
/**
* Will walk up the scene object graph to the closest $layout scene object
*/
export function getLayout(scene: SceneObject): SceneObject<SceneLayoutState> {
if (scene.constructor.name === 'SceneFlexLayout' || scene.constructor.name === 'SceneGridLayout') {
return scene as SceneObject<SceneLayoutState>;
}
if (scene.parent) {
return getLayout(scene.parent);
}
throw new Error('No layout found in scene tree');
}
/**
* Interpolates the given string using the current scene object as context. *
*/
export function interpolate(
sceneObject: SceneObject,
value: string | undefined | null,
scopedVars?: ScopedVars,
format?: string | CustomFormatterFn
): string {
// Skip interpolation if there are no variable dependencies
if (!value || !sceneObject.variableDependency || sceneObject.variableDependency.getNames().size === 0) {
return value ?? '';
}
return sceneInterpolator(sceneObject, value, scopedVars, format);
}
export const EmptyVariableSet = new SceneVariableSet({ variables: [] });
export const EmptyDataNode = new SceneDataNode({
data: {
state: LoadingState.Done,
series: [],
timeRange: getDefaultTimeRange(),
},
});
export const DefaultTimeRange = new SceneTimeRangeImpl();
export const sceneGraph = {
getVariables,
getData,
getTimeRange,
getSceneEditor,
getLayout,
interpolate,
};

View File

@@ -1,159 +0,0 @@
import React from 'react';
import { Observer, Subscription, Unsubscribable } from 'rxjs';
import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, TimeZone } from '@grafana/data';
import { SceneVariableDependencyConfigLike, SceneVariables } from '../variables/types';
export interface SceneObjectStatePlain {
key?: string;
$timeRange?: SceneTimeRangeLike;
$data?: SceneObject<SceneDataState>;
$editor?: SceneEditor;
$variables?: SceneVariables;
}
export interface SceneLayoutChildState extends SceneObjectStatePlain {
placement?: SceneLayoutChildOptions;
}
export type SceneObjectState = SceneObjectStatePlain | SceneLayoutState | SceneLayoutChildState;
export interface SceneLayoutChildOptions {
width?: number | string;
height?: number | string;
xSizing?: 'fill' | 'content';
ySizing?: 'fill' | 'content';
x?: number;
y?: number;
minWidth?: number | string;
minHeight?: number | string;
isDraggable?: boolean;
isResizable?: boolean;
}
export interface SceneComponentProps<T> {
model: T;
isEditing?: boolean;
}
export type SceneComponent<TModel> = React.FunctionComponent<SceneComponentProps<TModel>>;
export interface SceneDataState extends SceneObjectStatePlain {
data?: PanelData;
}
export interface SceneObject<TState extends SceneObjectState = SceneObjectState> {
/** The current state */
readonly state: TState;
/** True when there is a React component mounted for this Object */
readonly isActive: boolean;
/** 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;
/** This abstraction declares URL sync dependencies of a scene object. **/
readonly urlSync?: SceneObjectUrlSyncHandler<TState>;
/** Subscribe to state changes */
subscribeToState(observer?: Partial<Observer<TState>>): Subscription;
/** Subscribe to a scene event */
subscribeToEvent<T extends BusEvent>(typeFilter: BusEventType<T>, handler: BusEventHandler<T>): Unsubscribable;
/** Publish an event and optionally bubble it up the scene */
publishEvent(event: BusEvent, bubble?: boolean): void;
/** 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;
/** Called when the Component is mounted. A place to register event listeners add subscribe to state changes */
activate(): void;
/** Called when component unmounts. Unsubscribe and closes all subscriptions */
deactivate(): void;
/** Get the scene root */
getRoot(): SceneObject;
/** Returns a deep clone this object and all its 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;
/** Force a re-render, should only be needed when variable values change */
forceRender(): void;
}
export type SceneLayoutChild = SceneObject<SceneLayoutChildState | SceneLayoutState>;
export interface SceneLayoutState extends SceneLayoutChildState {
children: SceneLayoutChild[];
}
export type SceneLayout<T extends SceneLayoutState = SceneLayoutState> = SceneObject<T>;
export interface SceneEditorState extends SceneObjectStatePlain {
hoverObject?: SceneObjectRef;
selectedObject?: SceneObjectRef;
}
export interface SceneEditor extends SceneObject<SceneEditorState> {
onMouseEnterObject(model: SceneObject): void;
onMouseLeaveObject(model: SceneObject): void;
onSelectObject(model: SceneObject): void;
getEditComponentWrapper(): React.ComponentType<SceneComponentEditWrapperProps>;
}
interface SceneComponentEditWrapperProps {
editor: SceneEditor;
model: SceneObject;
children: React.ReactNode;
}
export interface SceneTimeRangeState extends SceneObjectStatePlain {
from: string;
to: string;
timeZone: TimeZone;
fiscalYearStartMonth?: number;
value: TimeRange;
}
export interface SceneTimeRangeLike 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;
}
export interface SceneObjectWithUrlSync<TState> extends SceneObject {
getUrlState(state: TState): SceneObjectUrlValues;
updateFromUrl(values: SceneObjectUrlValues): void;
}
export interface SceneObjectUrlSyncHandler<TState> {
getKeys(): string[];
getUrlState(state: TState): SceneObjectUrlValues;
updateFromUrl(values: SceneObjectUrlValues): void;
}
export type SceneObjectUrlValue = string | string[] | undefined | null;
export type SceneObjectUrlValues = Record<string, SceneObjectUrlValue>;

View File

@@ -1,56 +0,0 @@
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

@@ -2,14 +2,18 @@ import React from 'react';
import { PageLayoutType } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import {
UrlSyncManager,
SceneObjectBase,
SceneComponentProps,
SceneLayout,
SceneObject,
SceneObjectStatePlain,
} from '@grafana/scenes';
import { PageToolbar, ToolbarButton } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/Page/Page';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneLayout, SceneObject, SceneObjectStatePlain } from '../core/types';
import { UrlSyncManager } from '../services/UrlSyncManager';
interface DashboardSceneState extends SceneObjectStatePlain {
title: string;
uid: string;

View File

@@ -1,20 +1,23 @@
import {
CustomVariable,
DataSourceVariable,
QueryVariable,
SceneGridLayout,
SceneGridRow,
SceneQueryRunner,
VizPanel,
} from '@grafana/scenes';
import { defaultDashboard, LoadingState, Panel, RowPanel, VariableType } from '@grafana/schema';
import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
import { SceneGridLayout, SceneGridRow, VizPanel } from '../components';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
import { CustomVariable } from '../variables/variants/CustomVariable';
import { DataSourceVariable } from '../variables/variants/DataSourceVariable';
import { QueryVariable } from '../variables/variants/query/QueryVariable';
import { DashboardScene } from './DashboardScene';
import {
createDashboardSceneFromDashboardModel,
createVizPanelFromPanelModel,
createSceneVariableFromVariableModel,
DashboardLoader,
createVizPanelFromPanelModel,
} from './DashboardsLoader';
describe('DashboardLoader', () => {

View File

@@ -5,23 +5,28 @@ import {
QueryVariableModel,
VariableModel,
} from '@grafana/data';
import {
VizPanel,
SceneTimePicker,
SceneGridLayout,
SceneGridRow,
SceneTimeRange,
SceneObject,
SceneQueryRunner,
SceneSubMenu,
SceneVariableSet,
VariableValueSelectors,
SceneVariable,
CustomVariable,
DataSourceVariable,
QueryVariable,
ConstantVariable,
} from '@grafana/scenes';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { DashboardDTO } from 'app/types';
import { VizPanel, SceneTimePicker, SceneGridLayout, SceneGridRow, SceneSubMenu } from '../components';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneObject } from '../core/types';
import { SceneQueryRunner } from '../querying/SceneQueryRunner';
import { VariableValueSelectors } from '../variables/components/VariableValueSelectors';
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { SceneVariable } from '../variables/types';
import { ConstantVariable } from '../variables/variants/ConstantVariable';
import { CustomVariable } from '../variables/variants/CustomVariable';
import { DataSourceVariable } from '../variables/variants/DataSourceVariable';
import { QueryVariable } from '../variables/variants/query/QueryVariable';
import { DashboardScene } from './DashboardScene';
export interface DashboardLoaderState {

View File

@@ -2,10 +2,9 @@ import { css } from '@emotion/css';
import React, { CSSProperties } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneEditor, SceneObject } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { SceneEditor, SceneObject } from '../core/types';
export function SceneComponentEditWrapper({
model,
editor,

View File

@@ -2,11 +2,16 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneObjectBase,
SceneEditorState,
SceneEditor,
SceneObject,
SceneComponentProps,
SceneComponent,
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneEditorState, SceneEditor, SceneObject, SceneComponentProps, SceneComponent } from '../core/types';
import { SceneComponentEditWrapper } from './SceneComponentEditWrapper';
import { SceneObjectEditor } from './SceneObjectEditor';
import { SceneObjectTree } from './SceneObjectTree';

View File

@@ -1,9 +1,8 @@
import React from 'react';
import { SceneObject } from '@grafana/scenes';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { SceneObject } from '../core/types';
export interface Props {
model: SceneObject;
}

View File

@@ -2,11 +2,9 @@ import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { sceneGraph, SceneObject, isSceneObject, SceneLayoutChild } from '@grafana/scenes';
import { Icon, useStyles2 } from '@grafana/ui';
import { sceneGraph } from '../core/sceneGraph';
import { SceneObject, isSceneObject, SceneLayoutChild } from '../core/types';
export interface Props {
node: SceneObject;
selectedObject?: SceneObject;

View File

@@ -1,200 +0,0 @@
import { map, of } from 'rxjs';
import {
ArrayVector,
DataQueryRequest,
DataSourceApi,
getDefaultTimeRange,
LoadingState,
PanelData,
standardTransformersRegistry,
toDataFrame,
} from '@grafana/data';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneQueryRunner } from './SceneQueryRunner';
const getDatasource = () => {
return {
getRef: () => ({ uid: 'test' }),
};
};
jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: jest.fn(() => ({
get: getDatasource,
})),
}));
const runRequest = jest.fn().mockReturnValue(
of<PanelData>({
state: LoadingState.Done,
series: [
toDataFrame([
[100, 1],
[200, 2],
[300, 3],
]),
],
timeRange: getDefaultTimeRange(),
})
);
let sentRequest: DataQueryRequest | undefined;
jest.mock('app/features/query/state/runRequest', () => ({
runRequest: (ds: DataSourceApi, request: DataQueryRequest) => {
sentRequest = request;
return runRequest(ds, request);
},
}));
describe('SceneQueryRunner', () => {
describe('when activated and got no data', () => {
it('should run queries', async () => {
const queryRunner = new SceneQueryRunner({
queries: [{ refId: 'A' }],
$timeRange: new SceneTimeRange(),
});
expect(queryRunner.state.data).toBeUndefined();
queryRunner.activate();
await new Promise((r) => setTimeout(r, 1));
expect(queryRunner.state.data?.state).toBe(LoadingState.Done);
// Default max data points
expect(sentRequest?.maxDataPoints).toBe(500);
});
});
describe('when activated and maxDataPointsFromWidth set to true', () => {
it('should run queries', async () => {
const queryRunner = new SceneQueryRunner({
queries: [{ refId: 'A' }],
$timeRange: new SceneTimeRange(),
maxDataPointsFromWidth: true,
});
expect(queryRunner.state.data).toBeUndefined();
queryRunner.activate();
await new Promise((r) => setTimeout(r, 1));
expect(queryRunner.state.data?.state).toBeUndefined();
queryRunner.setContainerWidth(1000);
expect(queryRunner.state.data?.state).toBeUndefined();
await new Promise((r) => setTimeout(r, 1));
expect(queryRunner.state.data?.state).toBe(LoadingState.Done);
});
});
describe('transformations', () => {
let transformerSpy1 = jest.fn();
let transformerSpy2 = jest.fn();
beforeEach(() => {
standardTransformersRegistry.setInit(() => {
return [
{
id: 'customTransformer1',
editor: () => null,
transformation: {
id: 'customTransformer1',
name: 'Custom Transformer',
operator: (options) => (source) => {
transformerSpy1(options);
return source.pipe(
map((data) => {
return data.map((frame) => {
return {
...frame,
fields: frame.fields.map((field) => {
return {
...field,
values: new ArrayVector(field.values.toArray().map((v) => v * 2)),
};
}),
};
});
})
);
},
},
name: 'Custom Transformer',
},
{
id: 'customTransformer2',
editor: () => null,
transformation: {
id: 'customTransformer2',
name: 'Custom Transformer2',
operator: (options) => (source) => {
transformerSpy2(options);
return source.pipe(
map((data) => {
return data.map((frame) => {
return {
...frame,
fields: frame.fields.map((field) => {
return {
...field,
values: new ArrayVector(field.values.toArray().map((v) => v * 3)),
};
}),
};
});
})
);
},
},
name: 'Custom Transformer 2',
},
];
});
});
it('should apply transformations to query results', async () => {
const queryRunner = new SceneQueryRunner({
queries: [{ refId: 'A' }],
$timeRange: new SceneTimeRange(),
maxDataPoints: 100,
transformations: [
{
id: 'customTransformer1',
options: {
option: 'value1',
},
},
{
id: 'customTransformer2',
options: {
option: 'value2',
},
},
],
});
queryRunner.activate();
await new Promise((r) => setTimeout(r, 1));
expect(queryRunner.state.data?.state).toBe(LoadingState.Done);
expect(transformerSpy1).toHaveBeenCalledTimes(1);
expect(transformerSpy1).toHaveBeenCalledWith({ option: 'value1' });
expect(transformerSpy2).toHaveBeenCalledTimes(1);
expect(transformerSpy2).toHaveBeenCalledWith({ option: 'value2' });
expect(queryRunner.state.data?.series).toHaveLength(1);
expect(queryRunner.state.data?.series[0].fields).toHaveLength(2);
expect(queryRunner.state.data?.series[0].fields[0].values.toArray()).toEqual([600, 1200, 1800]);
expect(queryRunner.state.data?.series[0].fields[1].values.toArray()).toEqual([6, 12, 18]);
});
});
});

View File

@@ -1,205 +0,0 @@
import { cloneDeep } from 'lodash';
import { mergeMap, MonoTypeOperatorFunction, Unsubscribable, map, of } from 'rxjs';
import {
CoreApp,
DataQuery,
DataQueryRequest,
DataSourceApi,
DataSourceRef,
DataTransformerConfig,
PanelData,
rangeUtil,
ScopedVars,
TimeRange,
transformDataFrame,
} 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 { sceneGraph } from '../core/sceneGraph';
import { SceneObject, SceneObjectStatePlain } from '../core/types';
import { VariableDependencyConfig } from '../variables/VariableDependencyConfig';
export interface QueryRunnerState extends SceneObjectStatePlain {
data?: PanelData;
queries: DataQueryExtended[];
transformations?: DataTransformerConfig[];
datasource?: DataSourceRef;
minInterval?: string;
maxDataPoints?: number;
// Non persisted state
maxDataPointsFromWidth?: boolean;
}
export interface DataQueryExtended extends DataQuery {
[key: string]: any;
}
export class SceneQueryRunner extends SceneObjectBase<QueryRunnerState> {
private _querySub?: Unsubscribable;
private _containerWidth?: number;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['queries'],
onReferencedVariableValueChanged: () => this.runQueries(),
});
public activate() {
super.activate();
const timeRange = sceneGraph.getTimeRange(this);
this._subs.add(
timeRange.subscribeToState({
next: (timeRange) => {
this.runWithTimeRange(timeRange.value);
},
})
);
if (this.shouldRunQueriesOnActivate()) {
this.runQueries();
}
}
private shouldRunQueriesOnActivate() {
// If we already have data, no need
// TODO validate that time range is similar and if not we should run queries again
if (this.state.data) {
return false;
}
// If no maxDataPoints specified we need might to wait for container width to be set from the outside
if (!this.state.maxDataPoints && this.state.maxDataPointsFromWidth && !this._containerWidth) {
return false;
}
return true;
}
public deactivate(): void {
super.deactivate();
if (this._querySub) {
this._querySub.unsubscribe();
this._querySub = undefined;
}
}
public setContainerWidth(width: number) {
// If we don't have a width we should run queries
if (!this._containerWidth && width > 0) {
this._containerWidth = width;
// If we don't have maxDataPoints specifically set and maxDataPointsFromWidth is true
if (this.state.maxDataPointsFromWidth && !this.state.maxDataPoints) {
// As this is called from render path we need to wait for next tick before running queries
setTimeout(() => {
if (this.isActive && !this._querySub) {
this.runQueries();
}
}, 0);
}
} else {
// let's just remember the width until next query issue
this._containerWidth = width;
}
}
public runQueries() {
const timeRange = sceneGraph.getTimeRange(this);
this.runWithTimeRange(timeRange.state.value);
}
private getMaxDataPoints() {
return this.state.maxDataPoints ?? this._containerWidth ?? 500;
}
private async runWithTimeRange(timeRange: TimeRange) {
const { datasource, minInterval, queries } = this.state;
const request: DataQueryRequest = {
app: CoreApp.Dashboard,
requestId: getNextRequestId(),
timezone: 'browser',
panelId: 1,
dashboardId: 1,
range: timeRange,
interval: '1s',
intervalMs: 1000,
targets: cloneDeep(queries),
maxDataPoints: this.getMaxDataPoints(),
scopedVars: {},
startTime: Date.now(),
};
try {
const ds = await getDataSource(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;
});
// TODO interpolate minInterval
const lowerIntervalLimit = minInterval ? minInterval : ds.interval;
const norm = rangeUtil.calculateInterval(timeRange, request.maxDataPoints!, 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;
this._querySub = runRequest(ds, request)
.pipe(getTransformationsStream(this, this.state.transformations))
.subscribe({
next: this.onDataReceived,
});
} catch (err) {
console.error('PanelQueryRunner Error', err);
}
}
private onDataReceived = (data: PanelData) => {
this.setState({ data });
};
}
async function getDataSource(datasource: DataSourceRef | undefined, scopedVars: ScopedVars): Promise<DataSourceApi> {
if (datasource && (datasource as any).query) {
return datasource as DataSourceApi;
}
return await getDatasourceSrv().get(datasource as string, scopedVars);
}
export const getTransformationsStream: (
sceneObject: SceneObject,
transformations?: DataTransformerConfig[]
) => MonoTypeOperatorFunction<PanelData> = (sceneObject, transformations) => (inputStream) => {
return inputStream.pipe(
mergeMap((data) => {
if (!transformations || transformations.length === 0) {
return of(data);
}
const ctx = {
interpolate: (value: string) => {
return sceneGraph.interpolate(sceneObject, value, data?.request?.scopedVars);
},
};
return transformDataFrame(transformations, data.series, ctx).pipe(map((series) => ({ ...data, series })));
})
);
};

View File

@@ -1,20 +1,21 @@
import {
Scene,
SceneCanvasText,
ScenePanelRepeater,
SceneTimePicker,
SceneToolbarInput,
SceneFlexLayout,
SceneTimeRange,
SceneTimePicker,
ScenePanelRepeater,
VizPanel,
} from '../components';
import { EmbeddedScene } from '../components/Scene';
import { panelBuilders } from '../components/VizPanel/panelBuilders';
import { SceneTimeRange } from '../core/SceneTimeRange';
SceneCanvasText,
SceneToolbarInput,
EmbeddedScene,
} from '@grafana/scenes';
import { panelBuilders } from '../builders/panelBuilders';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getFlexLayoutTest(standalone: boolean): Scene {
export function getFlexLayoutTest(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Flex layout test',
body: new SceneFlexLayout({
@@ -66,7 +67,7 @@ export function getFlexLayoutTest(standalone: boolean): Scene {
return standalone ? new Scene(state) : new EmbeddedScene(state);
}
export function getScenePanelRepeaterTest(standalone: boolean): Scene {
export function getScenePanelRepeaterTest(standalone: boolean): Scene | EmbeddedScene {
const queryRunner = getQueryRunnerWithRandomWalkQuery({
seriesCount: 2,
alias: '__server_names',

View File

@@ -1,14 +1,18 @@
import { VizPanel } from '../components';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
SceneTimePicker,
SceneFlexLayout,
SceneGridLayout,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getGridLayoutTest(standalone: boolean): Scene {
export function getGridLayoutTest(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Grid layout test',
body: new SceneGridLayout({

View File

@@ -1,13 +1,18 @@
import { VizPanel, SceneGridRow } from '../components';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
SceneGridRow,
SceneTimePicker,
SceneGridLayout,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getGridWithMultipleTimeRanges(standalone: boolean): Scene {
export function getGridWithMultipleTimeRanges(standalone: boolean): Scene | EmbeddedScene {
const globalTimeRange = new SceneTimeRange();
const row1TimeRange = new SceneTimeRange({
from: 'now-1y',

View File

@@ -1,14 +1,18 @@
import { VizPanel } from '../components';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
SceneTimePicker,
SceneFlexLayout,
SceneGridLayout,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getMultipleGridLayoutTest(standalone: boolean): Scene {
export function getMultipleGridLayoutTest(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Multiple grid layouts test',
body: new SceneFlexLayout({

View File

@@ -1,13 +1,18 @@
import { VizPanel, SceneGridRow } from '../components';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
SceneGridRow,
SceneTimePicker,
SceneGridLayout,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getGridWithMultipleData(standalone: boolean): Scene {
export function getGridWithMultipleData(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Grid with rows and different queries',
body: new SceneGridLayout({

View File

@@ -1,12 +1,18 @@
import { VizPanel, SceneGridLayout, SceneGridRow } from '../components';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
SceneGridLayout,
SceneGridRow,
SceneTimePicker,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getGridWithRowLayoutTest(standalone: boolean): Scene {
export function getGridWithRowLayoutTest(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Grid with row layout test',
body: new SceneGridLayout({

View File

@@ -1,9 +1,13 @@
import { VizPanel, SceneGridRow } from '../components';
import {
VizPanel,
SceneGridRow,
SceneTimePicker,
SceneFlexLayout,
SceneGridLayout,
SceneTimeRange,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';

View File

@@ -1,3 +1,5 @@
import { EmbeddedScene, SceneObjectBase, SceneState } from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo';
@@ -14,7 +16,7 @@ import { getVariablesDemo, getVariablesDemoWithAll } from './variablesDemo';
interface SceneDef {
title: string;
getScene: (standalone: boolean) => Scene;
getScene: (standalone: boolean) => Scene | EmbeddedScene;
}
export function getScenes(): SceneDef[] {
return [
@@ -34,7 +36,7 @@ export function getScenes(): SceneDef[] {
];
}
const cache: Record<string, { standalone: boolean; scene: Scene }> = {};
const cache: Record<string, { standalone: boolean; scene: SceneObjectBase<SceneState> }> = {};
export function getSceneByTitle(title: string, standalone = true) {
if (cache[title]) {

View File

@@ -1,13 +1,17 @@
import { VizPanel } from '../components';
import { NestedScene } from '../components/NestedScene';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
NestedScene,
SceneTimePicker,
SceneFlexLayout,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getNestedScene(standalone: boolean): Scene {
export function getNestedScene(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Nested Scene demo',
body: new SceneFlexLayout({

View File

@@ -1,7 +1,6 @@
import { QueryRunnerState, SceneQueryRunner } from '@grafana/scenes';
import { TestDataQuery } from 'app/plugins/datasource/testdata/types';
import { QueryRunnerState, SceneQueryRunner } from '../querying/SceneQueryRunner';
export function getQueryRunnerWithRandomWalkQuery(
overrides?: Partial<TestDataQuery>,
queryRunnerOverrides?: Partial<QueryRunnerState>

View File

@@ -1,18 +1,21 @@
import { VariableRefresh } from '@grafana/data';
import {
SceneCanvasText,
SceneSubMenu,
SceneTimePicker,
SceneFlexLayout,
SceneTimeRange,
VariableValueSelectors,
SceneVariableSet,
CustomVariable,
DataSourceVariable,
QueryVariable,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene, EmbeddedScene } from '../components/Scene';
import { SceneCanvasText } from '../components/SceneCanvasText';
import { SceneSubMenu } from '../components/SceneSubMenu';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { VariableValueSelectors } from '../variables/components/VariableValueSelectors';
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { CustomVariable } from '../variables/variants/CustomVariable';
import { DataSourceVariable } from '../variables/variants/DataSourceVariable';
import { QueryVariable } from '../variables/variants/query/QueryVariable';
import { Scene } from '../components/Scene';
export function getQueryVariableDemo(standalone: boolean): Scene {
export function getQueryVariableDemo(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Query variable',
$variables: new SceneVariableSet({

View File

@@ -1,14 +1,18 @@
import { VizPanel } from '../components';
import { NestedScene } from '../components/NestedScene';
import { EmbeddedScene, Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
VizPanel,
NestedScene,
SceneTimePicker,
SceneFlexLayout,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getSceneWithRows(standalone: boolean): Scene {
export function getSceneWithRows(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Scene with rows',
body: new SceneFlexLayout({

View File

@@ -1,12 +1,18 @@
import { Scene, SceneTimePicker, SceneFlexLayout, VizPanel } from '../components';
import { EmbeddedScene } from '../components/Scene';
import { SceneDataTransformer } from '../core/SceneDataTransformer';
import { SceneTimeRange } from '../core/SceneTimeRange';
import {
SceneTimePicker,
SceneFlexLayout,
VizPanel,
SceneDataTransformer,
SceneTimeRange,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { SceneEditManager } from '../editor/SceneEditManager';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getTransformationsDemo(standalone: boolean): Scene {
export function getTransformationsDemo(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Transformations demo',
body: new SceneFlexLayout({

View File

@@ -1,19 +1,23 @@
import { VizPanel } from '../components';
import { Scene, EmbeddedScene } from '../components/Scene';
import { SceneCanvasText } from '../components/SceneCanvasText';
import { SceneSubMenu } from '../components/SceneSubMenu';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { VariableValueSelectors } from '../variables/components/VariableValueSelectors';
import { SceneVariableSet } from '../variables/sets/SceneVariableSet';
import { CustomVariable } from '../variables/variants/CustomVariable';
import { DataSourceVariable } from '../variables/variants/DataSourceVariable';
import { TestVariable } from '../variables/variants/TestVariable';
import {
VizPanel,
SceneCanvasText,
SceneSubMenu,
SceneTimePicker,
SceneFlexLayout,
SceneTimeRange,
VariableValueSelectors,
SceneVariableSet,
CustomVariable,
DataSourceVariable,
TestVariable,
EmbeddedScene,
} from '@grafana/scenes';
import { Scene } from '../components/Scene';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getVariablesDemo(standalone: boolean): Scene {
export function getVariablesDemo(standalone: boolean): Scene | EmbeddedScene {
const state = {
title: 'Variables',
$variables: new SceneVariableSet({

View File

@@ -1,30 +0,0 @@
import {
SceneObjectState,
SceneObjectUrlSyncHandler,
SceneObjectWithUrlSync,
SceneObjectUrlValues,
} from '../core/types';
interface SceneObjectUrlSyncConfigOptions {
keys: string[];
}
export class SceneObjectUrlSyncConfig<TState extends SceneObjectState> implements SceneObjectUrlSyncHandler<TState> {
private _keys: string[];
public constructor(private _sceneObject: SceneObjectWithUrlSync<TState>, _options: SceneObjectUrlSyncConfigOptions) {
this._keys = _options.keys;
}
public getKeys(): string[] {
return this._keys;
}
public getUrlState(state: TState): SceneObjectUrlValues {
return this._sceneObject.getUrlState(state);
}
public updateFromUrl(values: SceneObjectUrlValues): void {
this._sceneObject.updateFromUrl(values);
}
}

View File

@@ -1,202 +0,0 @@
import { Location } from 'history';
import { locationService } from '@grafana/runtime';
import { SceneFlexLayout } from '../components';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneLayoutChildState, SceneObjectUrlValues } from '../core/types';
import { SceneObjectUrlSyncConfig } from './SceneObjectUrlSyncConfig';
import { isUrlValueEqual, UrlSyncManager } from './UrlSyncManager';
interface TestObjectState extends SceneLayoutChildState {
name: string;
array?: string[];
other?: string;
}
class TestObj extends SceneObjectBase<TestObjectState> {
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['name', 'array'] });
public getUrlState(state: TestObjectState) {
return { name: state.name, array: state.array };
}
public updateFromUrl(values: SceneObjectUrlValues) {
if (typeof values.name === 'string') {
this.setState({ name: values.name ?? 'NA' });
}
if (Array.isArray(values.array)) {
this.setState({ array: values.array });
}
}
}
describe('UrlSyncManager', () => {
let urlManager: UrlSyncManager;
let locationUpdates: Location[] = [];
let listenUnregister: () => void;
beforeEach(() => {
locationUpdates = [];
listenUnregister = locationService.getHistory().listen((location) => {
locationUpdates.push(location);
});
});
afterEach(() => {
urlManager.cleanUp();
locationService.push('/');
listenUnregister();
});
describe('When state changes', () => {
it('should update url', () => {
const obj = new TestObj({ name: 'test' });
const scene = new SceneFlexLayout({
children: [obj],
});
urlManager = new UrlSyncManager(scene);
// When making state change
obj.setState({ name: 'test2' });
// Should update url
const searchObj = locationService.getSearchObject();
expect(searchObj.name).toBe('test2');
// When making unrelated state change
obj.setState({ other: 'not synced' });
// Should not update url
expect(locationUpdates.length).toBe(1);
});
});
describe('When url changes', () => {
it('should update state', () => {
const obj = new TestObj({ name: 'test' });
const initialObjState = obj.state;
const scene = new SceneFlexLayout({
children: [obj],
});
urlManager = new UrlSyncManager(scene);
// When non relevant key changes in url
locationService.partial({ someOtherProp: 'test2' });
// Should not affect state
expect(obj.state).toBe(initialObjState);
// When relevant key changes in url
locationService.partial({ name: 'test2' });
// Should update state
expect(obj.state.name).toBe('test2');
// When relevant key is cleared (say go back)
locationService.getHistory().goBack();
// Should revert to initial state
expect(obj.state.name).toBe('test');
// When relevant key is set to current state
const currentState = obj.state;
locationService.partial({ name: currentState.name });
// Should not affect state (same instance)
expect(obj.state).toBe(currentState);
});
});
describe('When multiple scene objects wants to set same url keys', () => {
it('should give each object a unique key', () => {
const outerTimeRange = new SceneTimeRange();
const innerTimeRange = new SceneTimeRange();
const scene = new SceneFlexLayout({
children: [
new SceneFlexLayout({
$timeRange: innerTimeRange,
children: [],
}),
],
$timeRange: outerTimeRange,
});
urlManager = new UrlSyncManager(scene);
// When making state changes for second object with same key
innerTimeRange.setState({ from: 'now-10m' });
// Should use unique key based where it is in the scene
expect(locationService.getSearchObject()).toEqual({
['from-2']: 'now-10m',
['to-2']: 'now',
});
outerTimeRange.setState({ from: 'now-20m' });
// Should not suffix key for first object
expect(locationService.getSearchObject()).toEqual({
from: 'now-20m',
to: 'now',
['from-2']: 'now-10m',
['to-2']: 'now',
});
// When updating via url
locationService.partial({ ['from-2']: 'now-10s' });
// should find the correct object
expect(innerTimeRange.state.from).toBe('now-10s');
// should not update the first object
expect(outerTimeRange.state.from).toBe('now-20m');
// Should not cause another url update
expect(locationUpdates.length).toBe(3);
});
});
describe('When updating array value', () => {
it('Should update url correctly', () => {
const obj = new TestObj({ name: 'test' });
const scene = new SceneFlexLayout({
children: [obj],
});
urlManager = new UrlSyncManager(scene);
// When making state change
obj.setState({ array: ['A', 'B'] });
// Should update url
const searchObj = locationService.getSearchObject();
expect(searchObj.array).toEqual(['A', 'B']);
// When making unrelated state change
obj.setState({ other: 'not synced' });
// Should not update url
expect(locationUpdates.length).toBe(1);
// When updating via url
locationService.partial({ array: ['A', 'B', 'C'] });
// Should update state
expect(obj.state.array).toEqual(['A', 'B', 'C']);
});
});
});
describe('isUrlValueEqual', () => {
it('should handle all cases', () => {
expect(isUrlValueEqual([], [])).toBe(true);
expect(isUrlValueEqual([], undefined)).toBe(true);
expect(isUrlValueEqual([], null)).toBe(true);
expect(isUrlValueEqual(['asd'], 'asd')).toBe(true);
expect(isUrlValueEqual(['asd'], ['asd'])).toBe(true);
expect(isUrlValueEqual(['asd', '2'], ['asd', '2'])).toBe(true);
expect(isUrlValueEqual(['asd', '2'], 'asd')).toBe(false);
expect(isUrlValueEqual(['asd2'], 'asd')).toBe(false);
});
});

View File

@@ -1,176 +0,0 @@
import { Location } from 'history';
import { isEqual } from 'lodash';
import { Unsubscribable } from 'rxjs';
import { locationService } from '@grafana/runtime';
import { SceneObjectStateChangedEvent } from '../core/events';
import { SceneObject, SceneObjectUrlValue, SceneObjectUrlValues } from '../core/types';
import { forEachSceneObjectInState } from '../core/utils';
export class UrlSyncManager {
private locationListenerUnsub: () => void;
private stateChangeSub: Unsubscribable;
private initialStates: Map<string, SceneObjectUrlValue> = new Map();
private urlKeyMapper = new UniqueUrlKeyMapper();
public constructor(private sceneRoot: SceneObject) {
this.stateChangeSub = sceneRoot.subscribeToEvent(SceneObjectStateChangedEvent, this.onStateChanged);
this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate);
}
/**
* Updates the current scene state to match URL state.
*/
public initSync() {
const urlParams = locationService.getSearch();
this.urlKeyMapper.rebuldIndex(this.sceneRoot);
this.syncSceneStateFromUrl(this.sceneRoot, urlParams);
}
private onLocationUpdate = (location: Location) => {
const urlParams = new URLSearchParams(location.search);
// Rebuild key mapper index before starting sync
this.urlKeyMapper.rebuldIndex(this.sceneRoot);
// Sync scene state tree from url
this.syncSceneStateFromUrl(this.sceneRoot, urlParams);
};
private onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => {
const changedObject = payload.changedObject;
if (changedObject.urlSync) {
const newUrlState = changedObject.urlSync.getUrlState(payload.newState);
const prevUrlState = changedObject.urlSync.getUrlState(payload.prevState);
const searchParams = locationService.getSearch();
const mappedUpdated: SceneObjectUrlValues = {};
this.urlKeyMapper.rebuldIndex(this.sceneRoot);
for (const [key, newUrlValue] of Object.entries(newUrlState)) {
const uniqueKey = this.urlKeyMapper.getUniqueKey(key, changedObject);
const currentUrlValue = searchParams.getAll(uniqueKey);
if (!isUrlValueEqual(currentUrlValue, newUrlValue)) {
mappedUpdated[uniqueKey] = newUrlValue;
// Remember the initial state so we can go back to it
if (!this.initialStates.has(uniqueKey) && prevUrlState[key] !== undefined) {
this.initialStates.set(uniqueKey, prevUrlState[key]);
}
}
}
if (Object.keys(mappedUpdated).length > 0) {
locationService.partial(mappedUpdated, true);
}
}
};
public cleanUp() {
this.stateChangeSub.unsubscribe();
this.locationListenerUnsub();
}
private syncSceneStateFromUrl(sceneObject: SceneObject, urlParams: URLSearchParams) {
if (sceneObject.urlSync) {
const urlState: SceneObjectUrlValues = {};
const currentState = sceneObject.urlSync.getUrlState(sceneObject.state);
for (const key of sceneObject.urlSync.getKeys()) {
const uniqueKey = this.urlKeyMapper.getUniqueKey(key, sceneObject);
const newValue = urlParams.getAll(uniqueKey);
const currentValue = currentState[key];
if (isUrlValueEqual(newValue, currentValue)) {
continue;
}
if (newValue.length > 0) {
if (Array.isArray(currentValue)) {
urlState[key] = newValue;
} else {
urlState[key] = newValue[0];
}
// Remember the initial state so we can go back to it
if (!this.initialStates.has(uniqueKey) && currentValue !== undefined) {
this.initialStates.set(uniqueKey, currentValue);
}
} else {
const initialValue = this.initialStates.get(uniqueKey);
if (initialValue !== undefined) {
urlState[key] = initialValue;
}
}
}
if (Object.keys(urlState).length > 0) {
sceneObject.urlSync.updateFromUrl(urlState);
}
}
forEachSceneObjectInState(sceneObject.state, (obj) => this.syncSceneStateFromUrl(obj, urlParams));
}
}
interface SceneObjectWithDepth {
sceneObject: SceneObject;
depth: number;
}
class UniqueUrlKeyMapper {
private index = new Map<string, SceneObjectWithDepth[]>();
public getUniqueKey(key: string, obj: SceneObject) {
const objectsWithKey = this.index.get(key);
if (!objectsWithKey) {
throw new Error("Cannot find any scene object that uses the key '" + key + "'");
}
const address = objectsWithKey.findIndex((o) => o.sceneObject === obj);
if (address > 0) {
return `${key}-${address + 1}`;
}
return key;
}
public rebuldIndex(root: SceneObject) {
this.index.clear();
this.buildIndex(root, 0);
}
private buildIndex(sceneObject: SceneObject, depth: number) {
if (sceneObject.urlSync) {
for (const key of sceneObject.urlSync.getKeys()) {
const hit = this.index.get(key);
if (hit) {
hit.push({ sceneObject, depth });
hit.sort((a, b) => a.depth - b.depth);
} else {
this.index.set(key, [{ sceneObject, depth }]);
}
}
}
forEachSceneObjectInState(sceneObject.state, (obj) => this.buildIndex(obj, depth + 1));
}
}
export function isUrlValueEqual(currentUrlValue: string[], newUrlValue: SceneObjectUrlValue): boolean {
if (currentUrlValue.length === 0 && newUrlValue == null) {
return true;
}
if (!Array.isArray(newUrlValue) && currentUrlValue?.length === 1) {
return newUrlValue === currentUrlValue[0];
}
if (newUrlValue?.length === 0 && currentUrlValue === null) {
return true;
}
// We have two arrays, lets compare them
return isEqual(currentUrlValue, newUrlValue);
}

View File

@@ -1,86 +0,0 @@
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

@@ -1,130 +0,0 @@
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

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

View File

@@ -1,74 +0,0 @@
import React from 'react';
import { VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Tooltip } from '@grafana/ui';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps, SceneObject, SceneObjectStatePlain } from '../../core/types';
import { SceneVariableState } from '../types';
export class VariableValueSelectors extends SceneObjectBase<SceneObjectStatePlain> {
public static Component = VariableValueSelectorsRenderer;
}
function VariableValueSelectorsRenderer({ model }: SceneComponentProps<VariableValueSelectors>) {
const variables = sceneGraph.getVariables(model)!.useState();
return (
<>
{variables.variables.map((variable) => (
<VariableValueSelectWrapper key={variable.state.key} variable={variable} />
))}
</>
);
}
function VariableValueSelectWrapper({ variable }: { variable: SceneObject<SceneVariableState> }) {
const state = variable.useState();
if (state.hide === VariableHide.hideVariable) {
return null;
}
return (
<div className="gf-form">
<VariableLabel state={state} />
<variable.Component model={variable} />
</div>
);
}
function VariableLabel({ state }: { state: SceneVariableState }) {
if (state.hide === VariableHide.hideLabel) {
return null;
}
const elementId = `var-${state.key}`;
const labelOrName = state.label ?? state.name;
if (state.description) {
return (
<Tooltip content={state.description} placement={'bottom'}>
<label
className="gf-form-label gf-form-label--variable"
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
htmlFor={elementId}
>
{labelOrName}
</label>
</Tooltip>
);
}
return (
<label
className="gf-form-label gf-form-label--variable"
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
htmlFor={elementId}
>
{labelOrName}
</label>
);
}

View File

@@ -1,12 +0,0 @@
import { getVariableDependencies } from './getVariableDependencies';
describe('getVariableDependencies', () => {
it('Can get dependencies', () => {
expect(getVariableDependencies('test.$plain ${withcurly} ${withformat:csv} [[deprecated]]')).toEqual([
'plain',
'withcurly',
'withformat',
'deprecated',
]);
});
});

View File

@@ -1,20 +0,0 @@
import { variableRegex } from 'app/features/variables/utils';
export function getVariableDependencies(stringToCheck: string): string[] {
variableRegex.lastIndex = 0;
const matches = stringToCheck.matchAll(variableRegex);
if (!matches) {
return [];
}
const dependencies: string[] = [];
for (const match of matches) {
const [, var1, var2, , var3] = match;
const variableName = var1 || var2 || var3;
dependencies.push(variableName);
}
return dependencies;
}

View File

@@ -1,73 +0,0 @@
import { property } from 'lodash';
import { ScopedVar } from '@grafana/data';
import { VariableValue } from '../types';
import { FormatVariable } from './formatRegistry';
export class ScopedVarsVariable implements FormatVariable {
private static fieldAccessorCache: FieldAccessorCache = {};
public state: { name: string; value: ScopedVar; type: string };
public constructor(name: string, value: ScopedVar) {
this.state = { name, value, type: 'scopedvar' };
}
public getValue(fieldPath: string): VariableValue {
let { value } = this.state;
let realValue = value.value;
if (fieldPath) {
realValue = this.getFieldAccessor(fieldPath)(value.value);
} else {
realValue = value.value;
}
if (realValue === 'string' || realValue === 'number' || realValue === 'boolean') {
return realValue;
}
return String(realValue);
}
public getValueText(): string {
const { value } = this.state;
if (value.text != null) {
return String(value.text);
}
return String(value);
}
private getFieldAccessor(fieldPath: string) {
const accessor = ScopedVarsVariable.fieldAccessorCache[fieldPath];
if (accessor) {
return accessor;
}
return (ScopedVarsVariable.fieldAccessorCache[fieldPath] = property(fieldPath));
}
}
interface FieldAccessorCache {
[key: string]: (obj: unknown) => unknown;
}
let scopedVarsVariable: ScopedVarsVariable | undefined;
/**
* Reuses a single instance to avoid unnecessary memory allocations
*/
export function getSceneVariableForScopedVar(name: string, value: ScopedVar) {
if (!scopedVarsVariable) {
scopedVarsVariable = new ScopedVarsVariable(name, value);
} else {
scopedVarsVariable.state.name = name;
scopedVarsVariable.state.value = value;
}
return scopedVarsVariable;
}

View File

@@ -1,68 +0,0 @@
import { VariableValue } from '../types';
import { TestVariable } from '../variants/TestVariable';
import { formatRegistry, FormatRegistryID } from './formatRegistry';
function formatValue<T extends VariableValue>(
formatId: FormatRegistryID,
value: T,
text?: string,
args: string[] = []
): string {
const variable = new TestVariable({ name: 'server', value, text });
return formatRegistry.get(formatId).formatter(value, args, variable);
}
describe('formatRegistry', () => {
it('Can format values acccording to format', () => {
expect(formatValue(FormatRegistryID.lucene, 'foo bar')).toBe('foo\\ bar');
expect(formatValue(FormatRegistryID.lucene, '-1')).toBe('-1');
expect(formatValue(FormatRegistryID.lucene, '-test')).toBe('\\-test');
expect(formatValue(FormatRegistryID.lucene, ['foo bar', 'baz'])).toBe('("foo\\ bar" OR "baz")');
expect(formatValue(FormatRegistryID.lucene, [])).toBe('__empty__');
expect(formatValue(FormatRegistryID.glob, 'foo')).toBe('foo');
expect(formatValue(FormatRegistryID.glob, ['AA', 'BB', 'C.*'])).toBe('{AA,BB,C.*}');
expect(formatValue(FormatRegistryID.text, 'v', 'display text')).toBe('display text');
expect(formatValue(FormatRegistryID.raw, [12, 13])).toBe('12,13');
expect(formatValue(FormatRegistryID.raw, '#Ƴ ̇¹"Ä1"#!"#!½')).toBe('#Ƴ ̇¹"Ä1"#!"#!½');
expect(formatValue(FormatRegistryID.regex, 'test.')).toBe('test\\.');
expect(formatValue(FormatRegistryID.regex, ['test.'])).toBe('test\\.');
expect(formatValue(FormatRegistryID.regex, ['test.', 'test2'])).toBe('(test\\.|test2)');
expect(formatValue(FormatRegistryID.pipe, ['test', 'test2'])).toBe('test|test2');
expect(formatValue(FormatRegistryID.distributed, ['test'])).toBe('test');
expect(formatValue(FormatRegistryID.distributed, ['test', 'test2'])).toBe('test,server=test2');
expect(formatValue(FormatRegistryID.csv, 'test')).toBe('test');
expect(formatValue(FormatRegistryID.csv, ['test', 'test2'])).toBe('test,test2');
expect(formatValue(FormatRegistryID.html, '<script>alert(asd)</script>')).toBe(
'&lt;script&gt;alert(asd)&lt;/script&gt;'
);
expect(formatValue(FormatRegistryID.json, ['test', 12])).toBe('["test",12]');
expect(formatValue(FormatRegistryID.percentEncode, ['foo()bar BAZ', 'test2'])).toBe(
'%7Bfoo%28%29bar%20BAZ%2Ctest2%7D'
);
expect(formatValue(FormatRegistryID.singleQuote, 'test')).toBe(`'test'`);
expect(formatValue(FormatRegistryID.singleQuote, ['test', `test'2`])).toBe("'test','test\\'2'");
expect(formatValue(FormatRegistryID.doubleQuote, 'test')).toBe(`"test"`);
expect(formatValue(FormatRegistryID.doubleQuote, ['test', `test"2`])).toBe('"test","test\\"2"');
expect(formatValue(FormatRegistryID.sqlString, "test'value")).toBe(`'test''value'`);
expect(formatValue(FormatRegistryID.sqlString, ['test', "test'value2"])).toBe(`'test','test''value2'`);
expect(formatValue(FormatRegistryID.date, 1594671549254)).toBe('2020-07-13T20:19:09.254Z');
expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['seconds'])).toBe('1594671549');
expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['iso'])).toBe('2020-07-13T20:19:09.254Z');
expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['YYYY-MM'])).toBe('2020-07');
});
});

View File

@@ -1,343 +0,0 @@
import { isArray, map, replace } from 'lodash';
import { dateTime, Registry, RegistryItem, textUtil, escapeRegex } from '@grafana/data';
import { VariableType } from '@grafana/schema';
import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { VariableValue, VariableValueSingle } from '../types';
export interface FormatRegistryItem extends RegistryItem {
formatter(value: VariableValue, args: string[], variable: FormatVariable): string;
}
/**
* Slimmed down version of the SceneVariable interface so that it only contains what the formatters actually use.
* This is useful as we have some implementations of this interface that does not need to be full scene objects.
* For example ScopedVarsVariable and LegacyVariableWrapper.
*/
export interface FormatVariable {
state: {
name: string;
type: VariableType | string;
};
getValue(fieldPath?: string): VariableValue | undefined | null;
getValueText?(fieldPath?: string): string;
}
export enum FormatRegistryID {
lucene = 'lucene',
raw = 'raw',
regex = 'regex',
pipe = 'pipe',
distributed = 'distributed',
csv = 'csv',
html = 'html',
json = 'json',
percentEncode = 'percentencode',
singleQuote = 'singlequote',
doubleQuote = 'doublequote',
sqlString = 'sqlstring',
date = 'date',
glob = 'glob',
text = 'text',
queryParam = 'queryparam',
}
export const formatRegistry = new Registry<FormatRegistryItem>(() => {
const formats: FormatRegistryItem[] = [
{
id: FormatRegistryID.lucene,
name: 'Lucene',
description: 'Values are lucene escaped and multi-valued variables generate an OR expression',
formatter: (value) => {
if (typeof value === 'string') {
return luceneEscape(value);
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '__empty__';
}
const quotedValues = map(value, (val: string) => {
return '"' + luceneEscape(val) + '"';
});
return '(' + quotedValues.join(' OR ') + ')';
} else {
return luceneEscape(`${value}`);
}
},
},
{
id: FormatRegistryID.raw,
name: 'raw',
description: 'Keep value as is',
formatter: (value) => String(value),
},
{
id: FormatRegistryID.regex,
name: 'Regex',
description: 'Values are regex escaped and multi-valued variables generate a (<value>|<value>) expression',
formatter: (value) => {
if (typeof value === 'string') {
return escapeRegex(value);
}
if (Array.isArray(value)) {
const escapedValues = value.map((item) => {
if (typeof item === 'string') {
return escapeRegex(item);
} else {
return escapeRegex(String(item));
}
});
if (escapedValues.length === 1) {
return escapedValues[0];
}
return '(' + escapedValues.join('|') + ')';
}
return escapeRegex(`${value}`);
},
},
{
id: FormatRegistryID.pipe,
name: 'Pipe',
description: 'Values are separated by | character',
formatter: (value) => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.join('|');
}
return `${value}`;
},
},
{
id: FormatRegistryID.distributed,
name: 'Distributed',
description: 'Multiple values are formatted like variable=value',
formatter: (value, args, variable) => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
value = map(value, (val: string, index: number) => {
if (index !== 0) {
return variable.state.name + '=' + val;
} else {
return val;
}
});
return value.join(',');
}
return `${value}`;
},
},
{
id: FormatRegistryID.csv,
name: 'Csv',
description: 'Comma-separated values',
formatter: (value) => {
if (typeof value === 'string') {
return value;
}
if (isArray(value)) {
return value.join(',');
}
return String(value);
},
},
{
id: FormatRegistryID.html,
name: 'HTML',
description: 'HTML escaping of values',
formatter: (value) => {
if (typeof value === 'string') {
return textUtil.escapeHtml(value);
}
if (isArray(value)) {
return textUtil.escapeHtml(value.join(', '));
}
return textUtil.escapeHtml(String(value));
},
},
{
id: FormatRegistryID.json,
name: 'JSON',
description: 'JSON stringify value',
formatter: (value) => {
return JSON.stringify(value);
},
},
{
id: FormatRegistryID.percentEncode,
name: 'Percent encode',
description: 'Useful for URL escaping values',
formatter: (value) => {
// like glob, but url escaped
if (isArray(value)) {
return encodeURIComponentStrict('{' + value.join(',') + '}');
}
return encodeURIComponentStrict(value);
},
},
{
id: FormatRegistryID.singleQuote,
name: 'Single quote',
description: 'Single quoted values',
formatter: (value) => {
// escape single quotes with backslash
const regExp = new RegExp(`'`, 'g');
if (isArray(value)) {
return map(value, (v: string) => `'${replace(v, regExp, `\\'`)}'`).join(',');
}
let strVal = typeof value === 'string' ? value : String(value);
return `'${replace(strVal, regExp, `\\'`)}'`;
},
},
{
id: FormatRegistryID.doubleQuote,
name: 'Double quote',
description: 'Double quoted values',
formatter: (value) => {
// escape double quotes with backslash
const regExp = new RegExp('"', 'g');
if (isArray(value)) {
return map(value, (v: string) => `"${replace(v, regExp, '\\"')}"`).join(',');
}
let strVal = typeof value === 'string' ? value : String(value);
return `"${replace(strVal, regExp, '\\"')}"`;
},
},
{
id: FormatRegistryID.sqlString,
name: 'SQL string',
description: 'SQL string quoting and commas for use in IN statements and other scenarios',
formatter: (value) => {
// escape single quotes by pairing them
const regExp = new RegExp(`'`, 'g');
if (isArray(value)) {
return map(value, (v: string) => `'${replace(v, regExp, "''")}'`).join(',');
}
let strVal = typeof value === 'string' ? value : String(value);
return `'${replace(strVal, regExp, "''")}'`;
},
},
{
id: FormatRegistryID.date,
name: 'Date',
description: 'Format date in different ways',
formatter: (value, args) => {
let nrValue = NaN;
if (typeof value === 'number') {
nrValue = value;
} else if (typeof value === 'string') {
nrValue = parseInt(value, 10);
}
if (isNaN(nrValue)) {
return 'NaN';
}
const arg = args[0] ?? 'iso';
switch (arg) {
case 'ms':
return String(value);
case 'seconds':
return `${Math.round(nrValue! / 1000)}`;
case 'iso':
return dateTime(nrValue).toISOString();
default:
return dateTime(nrValue).format(arg);
}
},
},
{
id: FormatRegistryID.glob,
name: 'Glob',
description: 'Format multi-valued variables using glob syntax, example {value1,value2}',
formatter: (value) => {
if (isArray(value) && value.length > 1) {
return '{' + value.join(',') + '}';
}
return String(value);
},
},
{
id: FormatRegistryID.text,
name: 'Text',
description: 'Format variables in their text representation. Example in multi-variable scenario A + B + C.',
formatter: (value, _args, variable) => {
if (variable.getValueText) {
return variable.getValueText();
}
return String(value);
},
},
{
id: FormatRegistryID.queryParam,
name: 'Query parameter',
description:
'Format variables as URL parameters. Example in multi-variable scenario A + B + C => var-foo=A&var-foo=B&var-foo=C.',
formatter: (value, _args, variable) => {
if (Array.isArray(value)) {
return value.map((v) => formatQueryParameter(variable.state.name, v)).join('&');
}
return formatQueryParameter(variable.state.name, value);
},
},
];
return formats;
});
function luceneEscape(value: string) {
if (isNaN(+value) === false) {
return value;
}
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
}
/**
* encode string according to RFC 3986; in contrast to encodeURIComponent()
* also the sub-delims "!", "'", "(", ")" and "*" are encoded;
* unicode handling uses UTF-8 as in ECMA-262.
*/
function encodeURIComponentStrict(str: VariableValueSingle) {
if (typeof str === 'object') {
str = String(str);
}
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => {
return '%' + c.charCodeAt(0).toString(16).toUpperCase();
});
}
function formatQueryParameter(name: string, value: VariableValueSingle): string {
return `var-${name}=${encodeURIComponentStrict(value)}`;
}
export function isAllValue(value: VariableValueSingle) {
return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE);
}

View File

@@ -1,171 +0,0 @@
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneObjectStatePlain } from '../../core/types';
import { SceneVariableSet } from '../sets/SceneVariableSet';
import { ConstantVariable } from '../variants/ConstantVariable';
import { ObjectVariable } from '../variants/ObjectVariable';
import { TestVariable } from '../variants/TestVariable';
import { sceneInterpolator } from './sceneInterpolator';
interface TestSceneState extends SceneObjectStatePlain {
nested?: TestScene;
}
class TestScene extends SceneObjectBase<TestSceneState> {}
describe('sceneInterpolator', () => {
it('Should be interpolated and use closest variable', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new ConstantVariable({
name: 'test',
value: 'hello',
}),
new ConstantVariable({
name: 'atRootOnly',
value: 'RootValue',
}),
],
}),
nested: new TestScene({
$variables: new SceneVariableSet({
variables: [
new ConstantVariable({
name: 'test',
value: 'nestedValue',
}),
],
}),
}),
});
expect(sceneInterpolator(scene, '${test}')).toBe('hello');
expect(sceneInterpolator(scene.state.nested!, '${test}')).toBe('nestedValue');
expect(sceneInterpolator(scene.state.nested!, '${atRootOnly}')).toBe('RootValue');
});
describe('Given a variable with allValue', () => {
it('Should not escape it', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'test',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
allValue: '.*',
}),
],
}),
});
expect(sceneInterpolator(scene, '${test:regex}')).toBe('.*');
});
});
describe('Given an expression with fieldPath', () => {
it('Should interpolate correctly', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new ObjectVariable({
type: 'custom',
name: 'test',
value: { prop1: 'prop1Value' },
}),
],
}),
});
expect(sceneInterpolator(scene, '${test.prop1}')).toBe('prop1Value');
});
});
it('Can use format', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new ConstantVariable({
name: 'test',
value: 'hello',
}),
],
}),
});
expect(sceneInterpolator(scene, '${test:queryparam}')).toBe('var-test=hello');
});
it('Can format multi valued values', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'test',
value: ['hello', 'world'],
}),
],
}),
});
expect(sceneInterpolator(scene, 'test.${test}.asd')).toBe('test.{hello,world}.asd');
});
it('Can format multi valued values using text formatter', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'test',
value: ['1', '2'],
text: ['hello', 'world'],
}),
],
}),
});
expect(sceneInterpolator(scene, '${test:text}')).toBe('hello + world');
});
it('Can use formats with arguments', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'test',
value: 1594671549254,
}),
],
}),
});
expect(sceneInterpolator(scene, '${test:date:YYYY-MM}')).toBe('2020-07');
});
it('Can use scopedVars', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [],
}),
});
const scopedVars = { __from: { value: 'a', text: 'b' } };
expect(sceneInterpolator(scene, '${__from}', scopedVars)).toBe('a');
expect(sceneInterpolator(scene, '${__from:text}', scopedVars)).toBe('b');
});
it('Can use scopedVars with fieldPath', () => {
const scene = new TestScene({
$variables: new SceneVariableSet({
variables: [],
}),
});
const scopedVars = { __data: { value: { name: 'Main org' }, text: '' } };
expect(sceneInterpolator(scene, '${__data.name}', scopedVars)).toBe('Main org');
});
});

View File

@@ -1,135 +0,0 @@
import { ScopedVars } from '@grafana/data';
import { VariableModel, VariableType } from '@grafana/schema';
import { variableRegex } from 'app/features/variables/utils';
import { EmptyVariableSet, sceneGraph } from '../../core/sceneGraph';
import { SceneObject } from '../../core/types';
import { VariableValue } from '../types';
import { getSceneVariableForScopedVar } from './ScopedVarsVariable';
import { formatRegistry, FormatRegistryID, FormatVariable } from './formatRegistry';
export type CustomFormatterFn = (
value: unknown,
legacyVariableModel: Partial<VariableModel>,
legacyDefaultFormatter?: CustomFormatterFn
) => string;
/**
* This function will try to parse and replace any variable expression found in the target string. The sceneObject will be used as the source of variables. It will
* use the scene graph and walk up the parent tree until it finds the closest variable.
*
* ScopedVars should not really be needed much in the new scene architecture as they can be added to the local scene node instead of passed in interpolate function.
* It is supported here for backward compatibility and some edge cases where adding scoped vars to local scene node is not practical.
*/
export function sceneInterpolator(
sceneObject: SceneObject,
target: string | undefined | null,
scopedVars?: ScopedVars,
format?: string | CustomFormatterFn
): string {
if (!target) {
return target ?? '';
}
// Skip any interpolation if there are no variables in the scene object graph
if (sceneGraph.getVariables(sceneObject) === EmptyVariableSet) {
return target;
}
variableRegex.lastIndex = 0;
return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => {
const variableName = var1 || var2 || var3;
const fmt = fmt2 || fmt3 || format;
let variable: FormatVariable | undefined | null;
if (scopedVars && scopedVars[variableName]) {
variable = getSceneVariableForScopedVar(variableName, scopedVars[variableName]);
} else {
variable = lookupSceneVariable(variableName, sceneObject);
}
if (!variable) {
return match;
}
return formatValue(variable, variable.getValue(fieldPath), fmt);
});
}
function lookupSceneVariable(name: string, sceneObject: SceneObject): FormatVariable | null | undefined {
const variables = sceneObject.state.$variables;
if (!variables) {
if (sceneObject.parent) {
return lookupSceneVariable(name, sceneObject.parent);
} else {
return null;
}
}
const found = variables.getByName(name);
if (found) {
return found;
} else if (sceneObject.parent) {
return lookupSceneVariable(name, sceneObject.parent);
}
return null;
}
function formatValue(
variable: FormatVariable,
value: VariableValue | undefined | null,
formatNameOrFn: string | CustomFormatterFn
): string {
if (value === null || value === undefined) {
return '';
}
// Special handling for custom values that should not be formatted / escaped
// This is used by the custom allValue that usually contain wildcards and therefore should not be escaped
if (typeof value === 'object' && 'isCustomValue' in value && formatNameOrFn !== FormatRegistryID.text) {
return value.toString();
}
// if (isAdHoc(variable) && format !== FormatRegistryID.queryParam) {
// return '';
// }
// if it's an object transform value to string
if (!Array.isArray(value) && typeof value === 'object') {
value = `${value}`;
}
if (typeof formatNameOrFn === 'function') {
return formatNameOrFn(value, {
name: variable.state.name,
type: variable.state.type as VariableType,
});
}
let args: string[] = [];
if (!formatNameOrFn) {
formatNameOrFn = FormatRegistryID.glob;
} else {
// some formats have arguments that come after ':' character
args = formatNameOrFn.split(':');
if (args.length > 1) {
formatNameOrFn = args[0];
args = args.slice(1);
} else {
args = [];
}
}
let formatter = formatRegistry.getIfExists(formatNameOrFn);
if (!formatter) {
console.error(`Variable format ${formatNameOrFn} not found. Using glob format as fallback.`);
formatter = formatRegistry.get(FormatRegistryID.glob);
}
return formatter.formatter(value, args, variable);
}

View File

@@ -1,161 +0,0 @@
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';
import { SceneVariableSet } from './SceneVariableSet';
interface TestSceneState extends SceneObjectStatePlain {
nested?: TestScene;
}
class TestScene extends SceneObjectBase<TestSceneState> {}
describe('SceneVariableList', () => {
describe('When activated', () => {
it('Should update variables in dependency order', async () => {
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
const B = new TestVariable({ name: 'B', query: 'A.$A', value: '', text: '', options: [] });
const C = new TestVariable({ name: 'C', query: 'A.$A.$B.*', value: '', text: '', options: [] });
const scene = new TestScene({
$variables: new SceneVariableSet({ variables: [C, B, A] }),
});
scene.activate();
// Should start variables with no dependencies
expect(A.state.loading).toBe(true);
expect(B.state.loading).toBe(undefined);
expect(C.state.loading).toBe(undefined);
// When A complete should start B
A.signalUpdateCompleted();
expect(A.state.value).toBe('AA');
expect(A.state.issuedQuery).toBe('A.*');
expect(A.state.loading).toBe(false);
expect(B.state.loading).toBe(true);
// Should wait with C as B is not completed yet
expect(C.state.loading).toBe(undefined);
// When B completes should now start C
B.signalUpdateCompleted();
expect(B.state.loading).toBe(false);
expect(C.state.loading).toBe(true);
// When C completes issue correct interpolated query containing the new values for A and B
C.signalUpdateCompleted();
expect(C.state.issuedQuery).toBe('A.AA.AAA.*');
});
});
describe('When variable changes value', () => {
it('Should start update process', async () => {
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
const B = new TestVariable({ name: 'B', query: 'A.$A.*', value: '', text: '', options: [] });
const C = new TestVariable({ name: 'C', query: 'A.$A.$B.*', value: '', text: '', options: [] });
const scene = new TestScene({
$variables: new SceneVariableSet({ variables: [C, B, A] }),
});
scene.activate();
A.signalUpdateCompleted();
B.signalUpdateCompleted();
C.signalUpdateCompleted();
// When changing A should start B but not C (yet)
A.changeValueTo('AB');
expect(B.state.loading).toBe(true);
expect(C.state.loading).toBe(false);
B.signalUpdateCompleted();
expect(B.state.value).toBe('ABA');
expect(C.state.loading).toBe(true);
});
});
describe('When deactivated', () => {
it('Should cancel running variable queries', async () => {
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
const scene = new TestScene({
$variables: new SceneVariableSet({ variables: [A] }),
});
scene.activate();
expect(A.isGettingValues).toBe(true);
scene.deactivate();
expect(A.isGettingValues).toBe(false);
});
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.changeValueTo('B');
});
expect(screen.getByText('AA - B')).toBeInTheDocument();
expect((helloText as any)._renderCount).toBe(1);
expect((sceneObjectWithVariable as any)._renderCount).toBe(3);
});
});
});
describe('When activated with variables update at the same time', () => {
it('Should not start variables multiple times', async () => {
const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] });
const B = new TestVariable({ name: 'B', query: 'B.*', value: '', text: '', options: [] });
const scene = new TestScene({
$variables: new SceneVariableSet({ variables: [A, B] }),
});
scene.activate();
// Should start variables
expect(A.state.loading).toBe(true);
expect(B.state.loading).toBe(true);
expect(A.getValueOptionsCount).toBe(1);
// Complete the second one
B.signalUpdateCompleted();
// When B complete should not start another instance of A
expect(A.getValueOptionsCount).toBe(1);
});
});
});

View File

@@ -1,198 +0,0 @@
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 Set<SceneVariable>();
/** Variables that are scheduled to be validated and updated */
private variablesToUpdate = new Set<SceneVariable>();
/** Variables currently updating */
private updating = new Map<SceneVariable, VariableUpdateInProgress>();
public getByName(name: string): SceneVariable | undefined {
// TODO: Replace with index
return this.state.variables.find((x) => x.state.name === name);
}
/**
* Subscribes to child variable value changes
* And starts the variable value validation process
*/
public activate(): void {
super.activate();
// Subscribe to changes to child variables
this._subs.add(this.subscribeToEvent(SceneVariableValueChangedEvent, this.onVariableValueChanged));
this.validateAndUpdateAll();
}
/**
* Cancel all currently running updates
*/
public deactivate(): void {
super.deactivate();
this.variablesToUpdate.clear();
for (const update of this.updating.values()) {
update.subscription?.unsubscribe();
}
}
/**
* This loops through variablesToUpdate and update all that that can.
* If one has a dependency that is currently in variablesToUpdate it will be skipped for now.
*/
private updateNextBatch() {
// 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');
}
// Ignore it if it's already started
if (this.updating.has(variable)) {
continue;
}
// Wait for variables that has dependencies that also needs updates
if (this.hasDependendencyInUpdateQueue(variable)) {
continue;
}
const variableToUpdate: VariableUpdateInProgress = {
variable,
};
this.updating.set(variable, variableToUpdate);
variableToUpdate.subscription = variable.validateAndUpdate().subscribe({
next: () => this.validateAndUpdateCompleted(variable),
error: (err) => this.handleVariableError(variable, err),
});
}
}
/**
* A variable has completed it's update process. This could mean that variables that depend on it can now be updated in turn.
*/
private validateAndUpdateCompleted(variable: SceneVariable) {
const update = this.updating.get(variable);
update?.subscription?.unsubscribe();
this.updating.delete(variable);
this.variablesToUpdate.delete(variable);
this.updateNextBatch();
}
/**
* TODO handle this properly (and show error in UI).
* Not sure if this should be handled here on in MultiValueVariable
*/
private handleVariableError(variable: SceneVariable, err: Error) {
const update = this.updating.get(variable);
update?.subscription?.unsubscribe();
this.updating.delete(variable);
this.variablesToUpdate.delete(variable);
variable.setState({ loading: false, error: err });
}
/**
* Checks if the variable has any dependencies that is currently in variablesToUpdate
*/
private hasDependendencyInUpdateQueue(variable: SceneVariable) {
if (!variable.variableDependency) {
return false;
}
for (const otherVariable of this.variablesToUpdate.values()) {
if (variable.variableDependency?.hasDependencyOn(otherVariable.state.name)) {
return true;
}
}
return false;
}
/**
* Extract dependencies from all variables and add those that needs update to the variablesToUpdate map
* Then it will start the update process.
*/
private validateAndUpdateAll() {
for (const variable of this.state.variables) {
if (variable.validateAndUpdate) {
this.variablesToUpdate.add(variable);
}
}
this.updateNextBatch();
}
/**
* This will trigger an update of all variables that depend on it.
* */
private onVariableValueChanged = (event: SceneVariableValueChangedEvent) => {
const variableThatChanged = event.payload;
this.variablesThatHaveChanged.add(variableThatChanged);
// Ignore this change if it is currently updating
if (this.updating.has(variableThatChanged)) {
return;
}
// 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 {
variable: SceneVariable;
subscription?: Unsubscribable;
}

View File

@@ -1,81 +0,0 @@
import { Observable } from 'rxjs';
import { BusEventWithPayload } from '@grafana/data';
import { VariableType } from '@grafana/schema';
import { VariableHide } from 'app/features/variables/types';
import { SceneObject, SceneObjectStatePlain } from '../core/types';
export interface SceneVariableState extends SceneObjectStatePlain {
type: VariableType;
name: string;
label?: string;
hide?: VariableHide;
skipUrlSync?: boolean;
loading?: boolean;
error?: any | null;
description?: string | null;
}
export interface SceneVariable<TState extends SceneVariableState = SceneVariableState> extends SceneObject<TState> {
/**
* This function is called on activation or when a dependency changes.
*/
validateAndUpdate?(): Observable<ValidateAndUpdateResult>;
/**
* Should return the value for the given field path
*/
getValue(fieldPath?: string): VariableValue | undefined | null;
/**
* Should return the value display text, used by the "text" formatter
* Example: ${podId:text}
* Useful for variables that have non user friendly values but friendly display text names.
*/
getValueText?(fieldPath?: string): string;
}
export type VariableValue = VariableValueSingle | VariableValueSingle[];
export type VariableValueSingle = string | boolean | number | VariableValueCustom;
/**
* This is for edge case values like the custom "allValue" that should not be escaped/formatted like other values.
* The custom all value usually contain wildcards that should not be escaped.
*/
export interface VariableValueCustom {
isCustomValue: true;
toString(): string;
}
export interface ValidateAndUpdateResult {}
export interface VariableValueOption {
label: string;
value: VariableValueSingle;
}
export interface SceneVariableSetState extends SceneObjectStatePlain {
variables: SceneVariable[];
}
export interface SceneVariables extends SceneObject<SceneVariableSetState> {
getByName(name: string): SceneVariable | undefined;
}
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.
**/
variableValuesChanged(variables: Set<SceneVariable>): void;
}

View File

@@ -1,23 +0,0 @@
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneVariable, SceneVariableState, VariableValue } from '../types';
export interface ConstantVariableState extends SceneVariableState {
value: VariableValue;
}
export class ConstantVariable
extends SceneObjectBase<ConstantVariableState>
implements SceneVariable<ConstantVariableState>
{
public constructor(initialState: Partial<ConstantVariableState>) {
super({
type: 'constant',
value: '',
name: '',
...initialState,
});
}
public getValue(): VariableValue {
return this.state.value;
}
}

View File

@@ -1,234 +0,0 @@
import { lastValueFrom } from 'rxjs';
import { CustomVariable } from './CustomVariable';
describe('CustomVariable', () => {
describe('When empty query is provided', () => {
it('Should default to empty options', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: '',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('');
expect(variable.state.text).toEqual('');
expect(variable.state.options).toEqual([]);
});
});
describe('When query is provided', () => {
it('Should generate correctly the options for only value queries', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'A,B,C',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('A');
expect(variable.state.text).toEqual('A');
expect(variable.state.options).toEqual([
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
]);
});
it('Should generate correctly the options for key:value pairs', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'label-1 : value-1,label-2 : value-2, label-3 : value-3',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('value-1');
expect(variable.state.text).toEqual('label-1');
expect(variable.state.options).toEqual([
{ label: 'label-1', value: 'value-1' },
{ label: 'label-2', value: 'value-2' },
{ label: 'label-3', value: 'value-3' },
]);
});
it('Should generate correctly the options for key:value pairs with special characters', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'label\\,1 : value\\,1',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('value,1');
expect(variable.state.text).toEqual('label,1');
expect(variable.state.options).toEqual([{ label: 'label,1', value: 'value,1' }]);
});
it('Should generate correctly the options for key:value and only values combined', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'label-1 : value-1, value-2, label\\,3 : value-3,value\\,4',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('value-1');
expect(variable.state.text).toEqual('label-1');
expect(variable.state.options).toEqual([
{ label: 'label-1', value: 'value-1' },
{ label: 'value-2', value: 'value-2' },
{ label: 'label,3', value: 'value-3' },
{ label: 'value,4', value: 'value,4' },
]);
});
it('Should generate correctly the options for key:value pairs with extra spaces', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'a, b, c, d : e',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('a');
expect(variable.state.text).toEqual('a');
expect(variable.state.options).toEqual([
{
label: 'a',
value: 'a',
},
{
label: 'b',
value: 'b',
},
{
label: 'c',
value: 'c',
},
{
label: 'd',
value: 'e',
},
]);
});
it('Should generate correctly the options for only values as URLs', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'http://www.google.com/, http://www.amazon.com/',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('http://www.google.com/');
expect(variable.state.text).toEqual('http://www.google.com/');
expect(variable.state.options).toEqual([
{
label: 'http://www.google.com/',
value: 'http://www.google.com/',
},
{
label: 'http://www.amazon.com/',
value: 'http://www.amazon.com/',
},
]);
});
it('Should generate correctly the options for key/values as URLs', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'google : http://www.google.com/, amazon : http://www.amazon.com/',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('http://www.google.com/');
expect(variable.state.text).toEqual('google');
expect(variable.state.options).toEqual([
{
label: 'google',
value: 'http://www.google.com/',
},
{
label: 'amazon',
value: 'http://www.amazon.com/',
},
]);
});
});
describe('When value is provided', () => {
it('Should keep current value if current value is valid', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
query: 'A,B',
value: 'B',
text: 'B',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe('B');
expect(variable.state.text).toBe('B');
});
it('Should maintain the valid values when multiple selected', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
isMulti: true,
query: 'A,C',
value: ['A', 'B', 'C'],
text: ['A', 'B', 'C'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual(['A', 'C']);
expect(variable.state.text).toEqual(['A', 'C']);
});
it('Should pick first option if none of the current values are valid', async () => {
const variable = new CustomVariable({
name: 'test',
options: [],
isMulti: true,
query: 'A,C',
value: ['D', 'E'],
text: ['E', 'E'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual(['A']);
expect(variable.state.text).toEqual(['A']);
});
});
});

View File

@@ -1,50 +0,0 @@
import { Observable, of } from 'rxjs';
import { SceneComponentProps } from '../../core/types';
import { VariableDependencyConfig } from '../VariableDependencyConfig';
import { renderSelectForVariable } from '../components/VariableValueSelect';
import { VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
export interface CustomVariableState extends MultiValueVariableState {
query: string;
}
export class CustomVariable extends MultiValueVariable<CustomVariableState> {
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['query'],
});
public constructor(initialState: Partial<CustomVariableState>) {
super({
type: 'custom',
query: '',
value: '',
text: '',
options: [],
name: '',
...initialState,
});
}
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
const match = this.state.query.match(/(?:\\,|[^,])+/g) ?? [];
const options = match.map((text) => {
text = text.replace(/\\,/g, ',');
const textMatch = /^(.+)\s:\s(.+)$/g.exec(text) ?? [];
if (textMatch.length === 3) {
const [, key, value] = textMatch;
return { label: key.trim(), value: value.trim() };
} else {
return { label: text.trim(), value: text.trim() };
}
});
return of(options);
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return renderSelectForVariable(model);
};
}

View File

@@ -1,227 +0,0 @@
import { lastValueFrom } from 'rxjs';
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data';
import { getMockPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { SceneObject } from '../../core/types';
import { CustomFormatterFn } from '../interpolation/sceneInterpolator';
import { DataSourceVariable } from './DataSourceVariable';
function getDataSource(name: string, type: string, isDefault = false): DataSourceInstanceSettings {
return {
id: 1,
uid: 'c8eceabb-0275-4108-8f03-8f74faf4bf6d',
type,
name,
meta: getMockPlugin({ name, id: type }),
jsonData: {},
access: 'proxy',
readOnly: false,
isDefault,
};
}
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getList: () => [
getDataSource('prometheus-mocked', 'prometheus'),
getDataSource('slow-prometheus-mocked', 'prometheus', true),
getDataSource('elastic-mocked', 'elastic'),
],
}),
}));
jest.mock('../../core/sceneGraph', () => {
return {
...jest.requireActual('../../core/sceneGraph'),
sceneGraph: {
interpolate: (
sceneObject: SceneObject,
value: string | undefined | null,
scopedVars?: ScopedVars,
format?: string | CustomFormatterFn
) => {
return value?.replace('$variable-1', 'slow');
},
},
};
});
describe('DataSourceVariable', () => {
describe('When empty query is provided', () => {
it('Should default to empty options and empty value', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
value: '',
text: '',
query: '',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('');
expect(variable.state.text).toEqual('');
expect(variable.state.options).toEqual([]);
});
});
describe('When query is provided', () => {
it('Should default to non datasources found options for invalid query', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'non-existant-datasource',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('');
expect(variable.state.text).toEqual('');
expect(variable.state.options).toEqual([
{
label: 'No data sources found',
value: '',
},
]);
});
it('Should default to first item datasource when options available', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'prometheus',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('prometheus-mocked');
expect(variable.state.text).toEqual('prometheus-mocked');
expect(variable.state.options).toEqual([
{
label: 'prometheus-mocked',
value: 'prometheus-mocked',
},
{
label: 'slow-prometheus-mocked',
value: 'slow-prometheus-mocked',
},
{
label: 'default',
value: 'default',
},
]);
});
it('Should generate correctly the options including only datasources with the queried type', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'prometheus',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('prometheus-mocked');
expect(variable.state.text).toEqual('prometheus-mocked');
expect(variable.state.options).toEqual([
{ label: 'prometheus-mocked', value: 'prometheus-mocked' },
{ label: 'slow-prometheus-mocked', value: 'slow-prometheus-mocked' },
{ label: 'default', value: 'default' },
]);
});
});
describe('When regex is provided', () => {
it('Should generate correctly the options including only datasources with matching', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'prometheus',
regex: 'slow.*',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('slow-prometheus-mocked');
expect(variable.state.text).toEqual('slow-prometheus-mocked');
expect(variable.state.options).toEqual([{ label: 'slow-prometheus-mocked', value: 'slow-prometheus-mocked' }]);
});
it('Should generate correctly the options after interpolating variables', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
value: '',
text: '',
query: 'prometheus',
regex: '$variable-1.*',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('slow-prometheus-mocked');
expect(variable.state.text).toEqual('slow-prometheus-mocked');
expect(variable.state.options).toEqual([{ label: 'slow-prometheus-mocked', value: 'slow-prometheus-mocked' }]);
});
});
describe('When value is provided', () => {
it('Should keep current value if current value is valid', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
query: 'prometheus',
value: 'slow-prometheus-mocked',
text: 'slow-prometheus-mocked',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe('slow-prometheus-mocked');
expect(variable.state.text).toBe('slow-prometheus-mocked');
});
it('Should maintain the valid values when multiple selected', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
isMulti: true,
query: 'prometheus',
value: ['prometheus-mocked', 'slow-prometheus-mocked', 'elastic-mocked'],
text: ['prometheus-mocked', 'slow-prometheus-mocked', 'elastic-mocked'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual(['prometheus-mocked', 'slow-prometheus-mocked']);
expect(variable.state.text).toEqual(['prometheus-mocked', 'slow-prometheus-mocked']);
});
it('Should pick first option if none of the current values are valid', async () => {
const variable = new DataSourceVariable({
name: 'test',
options: [],
isMulti: true,
query: 'elastic',
value: ['prometheus-mocked', 'slow-prometheus-mocked'],
text: ['prometheus-mocked', 'slow-prometheus-mocked'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual(['elastic-mocked']);
expect(variable.state.text).toEqual(['elastic-mocked']);
});
});
});

View File

@@ -1,102 +0,0 @@
import { Observable, of } from 'rxjs';
import { stringToJsRegex, DataSourceInstanceSettings } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps } from '../../core/types';
import { VariableDependencyConfig } from '../VariableDependencyConfig';
import { renderSelectForVariable } from '../components/VariableValueSelect';
import { VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
export interface DataSourceVariableState extends MultiValueVariableState {
query: string;
regex: string;
}
export class DataSourceVariable extends MultiValueVariable<DataSourceVariableState> {
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['regex'],
});
public constructor(initialState: Partial<DataSourceVariableState>) {
super({
type: 'datasource',
value: '',
text: '',
options: [],
name: '',
regex: '',
query: '',
...initialState,
});
}
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
if (!this.state.query) {
return of([]);
}
const dataSourceTypes = this.getDataSourceTypes();
let regex;
if (this.state.regex) {
const interpolated = sceneGraph.interpolate(this, this.state.regex, undefined, 'regex');
regex = stringToJsRegex(interpolated);
}
const options: VariableValueOption[] = [];
for (let i = 0; i < dataSourceTypes.length; i++) {
const source = dataSourceTypes[i];
// must match on type
if (source.meta.id !== this.state.query) {
continue;
}
if (isValid(source, regex)) {
options.push({ label: source.name, value: source.name });
}
if (isDefault(source, regex)) {
options.push({ label: 'default', value: 'default' });
}
}
if (options.length === 0) {
options.push({ label: 'No data sources found', value: '' });
}
return of(options);
}
private getDataSourceTypes(): DataSourceInstanceSettings[] {
return getDataSourceSrv().getList({ metrics: true, variables: false });
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return renderSelectForVariable(model);
};
}
function isValid(source: DataSourceInstanceSettings, regex?: RegExp) {
if (!regex) {
return true;
}
return regex.exec(source.name);
}
function isDefault(source: DataSourceInstanceSettings, regex?: RegExp) {
if (!source.isDefault) {
return false;
}
if (!regex) {
return true;
}
return regex.exec('default');
}

View File

@@ -1,374 +0,0 @@
import { lastValueFrom, Observable, of } from 'rxjs';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { SceneVariableValueChangedEvent, VariableValueCustom, VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../variants/MultiValueVariable';
export interface ExampleVariableState extends MultiValueVariableState {
optionsToReturn: VariableValueOption[];
}
class ExampleVariable extends MultiValueVariable<ExampleVariableState> {
public constructor(initialState: Partial<ExampleVariableState>) {
super({
type: 'custom',
optionsToReturn: [],
value: '',
text: '',
name: '',
options: [],
...initialState,
});
}
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
return of(this.state.optionsToReturn);
}
}
describe('MultiValueVariable', () => {
describe('When validateAndUpdate is called', () => {
it('Should pick first value if current value is not valid', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
value: 'A',
text: 'A',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe('B');
expect(variable.state.text).toBe('B');
});
it('Should pick All value when defaultToAll is true', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
defaultToAll: true,
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe(ALL_VARIABLE_VALUE);
});
it('Should keep current value if current value is valid', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [{ label: 'A', value: 'A' }],
value: 'A',
text: 'A',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe('A');
expect(variable.state.text).toBe('A');
});
it('Should maintain the valid values when multiple selected', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
isMulti: true,
optionsToReturn: [
{ label: 'A', value: 'A' },
{ label: 'C', value: 'C' },
],
value: ['A', 'B', 'C'],
text: ['A', 'B', 'C'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual(['A', 'C']);
expect(variable.state.text).toEqual(['A', 'C']);
});
it('Should pick first option if none of the current values are valid', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
isMulti: true,
optionsToReturn: [
{ label: 'A', value: 'A' },
{ label: 'C', value: 'C' },
],
value: ['D', 'E'],
text: ['E', 'E'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual(['A']);
expect(variable.state.text).toEqual(['A']);
});
it('Should select All option if none of the current values are valid', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
isMulti: true,
defaultToAll: true,
optionsToReturn: [
{ label: 'A', value: 'A' },
{ label: 'C', value: 'C' },
],
value: ['D', 'E'],
text: ['E', 'E'],
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual([ALL_VARIABLE_VALUE]);
expect(variable.state.text).toEqual([ALL_VARIABLE_TEXT]);
});
it('Should handle $__all value and send change event even when value is still $__all', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
});
let changeEvent: SceneVariableValueChangedEvent | undefined;
variable.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => (changeEvent = evt));
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toBe(ALL_VARIABLE_VALUE);
expect(variable.state.text).toBe(ALL_VARIABLE_TEXT);
expect(variable.state.options).toEqual(variable.state.optionsToReturn);
expect(changeEvent).toBeDefined();
});
});
describe('changeValueTo', () => {
it('Should set default empty state to all value if defaultToAll multi', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
isMulti: true,
defaultToAll: true,
optionsToReturn: [],
value: ['1'],
text: ['A'],
});
variable.changeValueTo([]);
expect(variable.state.value).toEqual([ALL_VARIABLE_VALUE]);
});
it('When changing to all value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
isMulti: true,
defaultToAll: true,
optionsToReturn: [],
value: ['1'],
text: ['A'],
});
variable.changeValueTo(['1', ALL_VARIABLE_VALUE]);
// Should clear the value so only all value is set
expect(variable.state.value).toEqual([ALL_VARIABLE_VALUE]);
});
it('When changing from all value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
isMulti: true,
defaultToAll: true,
optionsToReturn: [],
});
variable.changeValueTo([ALL_VARIABLE_VALUE, '1']);
// Should remove the all value so only the new value is present
expect(variable.state.value).toEqual(['1']);
});
});
describe('getValue and getValueText', () => {
it('GetValueText should return text', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: '1',
text: 'A',
});
expect(variable.getValue()).toBe('1');
expect(variable.getValueText()).toBe('A');
});
it('GetValueText should return All text when value is $__all', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: ALL_VARIABLE_VALUE,
text: 'A',
});
expect(variable.getValueText()).toBe(ALL_VARIABLE_TEXT);
});
it('GetValue should return all options as an array when value is $__all', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
optionsToReturn: [],
value: ALL_VARIABLE_VALUE,
text: 'A',
});
expect(variable.getValue()).toEqual(['1', '2']);
});
it('GetValue should return allValue when value is $__all', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: ALL_VARIABLE_VALUE,
allValue: '.*',
text: 'A',
});
const value = variable.getValue() as VariableValueCustom;
expect(value.isCustomValue).toBe(true);
expect(value.toString()).toBe('.*');
});
});
describe('getOptionsForSelect', () => {
it('Should return options', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [{ label: 'A', value: '1' }],
optionsToReturn: [],
value: '1',
text: 'A',
});
expect(variable.getOptionsForSelect()).toEqual([{ label: 'A', value: '1' }]);
});
it('Should return include All option when includeAll is true', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [{ label: 'A', value: '1' }],
optionsToReturn: [],
includeAll: true,
value: '1',
text: 'A',
});
expect(variable.getOptionsForSelect()).toEqual([
{ label: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE },
{ label: 'A', value: '1' },
]);
});
it('Should add current value if not found', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: '1',
text: 'A',
});
expect(variable.getOptionsForSelect()).toEqual([{ label: 'A', value: '1' }]);
});
});
describe('Url syncing', () => {
it('getUrlState should return single value state if value is single value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: '1',
text: 'A',
});
expect(variable.urlSync?.getUrlState(variable.state)).toEqual({ ['var-test']: '1' });
});
it('getUrlState should return string array if value is string array', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [],
optionsToReturn: [],
value: ['1', '2'],
text: ['A', 'B'],
});
expect(variable.urlSync?.getUrlState(variable.state)).toEqual({ ['var-test']: ['1', '2'] });
});
it('fromUrlState should update value for single value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
optionsToReturn: [],
value: '1',
text: 'A',
});
variable.urlSync?.updateFromUrl({ ['var-test']: '2' });
expect(variable.state.value).toEqual('2');
expect(variable.state.text).toEqual('B');
});
it('fromUrlState should update value for array value', async () => {
const variable = new ExampleVariable({
name: 'test',
options: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
],
optionsToReturn: [],
value: '1',
text: 'A',
});
variable.urlSync?.updateFromUrl({ ['var-test']: ['2', '1'] });
expect(variable.state.value).toEqual(['2', '1']);
expect(variable.state.text).toEqual(['B', 'A']);
});
});
});

View File

@@ -1,282 +0,0 @@
import { isEqual } from 'lodash';
import { map, Observable } from 'rxjs';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneObject, SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '../../core/types';
import {
SceneVariable,
SceneVariableValueChangedEvent,
SceneVariableState,
ValidateAndUpdateResult,
VariableValue,
VariableValueOption,
VariableValueCustom,
VariableValueSingle,
} from '../types';
export interface MultiValueVariableState extends SceneVariableState {
value: VariableValue; // old current.text
text: VariableValue; // old current.value
options: VariableValueOption[];
isMulti?: boolean;
includeAll?: boolean;
defaultToAll?: boolean;
allValue?: string;
placeholder?: string;
}
export interface VariableGetOptionsArgs {
searchFilter?: string;
}
export abstract class MultiValueVariable<TState extends MultiValueVariableState = MultiValueVariableState>
extends SceneObjectBase<TState>
implements SceneVariable<TState>
{
protected _urlSync: SceneObjectUrlSyncHandler<TState> = new MultiValueUrlSyncHandler(this);
/**
* The source of value options.
*/
public abstract getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]>;
/**
* This function is called on when SceneVariableSet is activated or when a dependency changes.
*/
public validateAndUpdate(): Observable<ValidateAndUpdateResult> {
return this.getValueOptions({}).pipe(
map((options) => {
this.updateValueGivenNewOptions(options);
return {};
})
);
}
/**
* Check if current value is valid given new options. If not update the value.
*/
private updateValueGivenNewOptions(options: VariableValueOption[]) {
const stateUpdate: Partial<MultiValueVariableState> = {
options,
loading: false,
value: this.state.value,
text: this.state.text,
};
if (options.length === 0) {
// TODO handle the no value state
} else if (this.hasAllValue()) {
// If value is set to All then we keep it set to All but just store the options
} else if (this.state.isMulti) {
// If we are a multi valued variable validate the current values are among the options
const currentValues = Array.isArray(this.state.value) ? this.state.value : [this.state.value];
const validValues = currentValues.filter((v) => options.find((o) => o.value === v));
// If no valid values pick the first option
if (validValues.length === 0) {
const defaultState = this.getDefaultMultiState(options);
stateUpdate.value = defaultState.value;
stateUpdate.text = defaultState.text;
}
// We have valid values, if it's different from current valid values update current values
else if (!isEqual(validValues, this.state.value)) {
const validTexts = validValues.map((v) => options.find((o) => o.value === v)!.label);
stateUpdate.value = validValues;
stateUpdate.text = validTexts;
}
} else {
// Single valued variable
const foundCurrent = options.find((x) => x.value === this.state.value);
if (!foundCurrent) {
if (this.state.defaultToAll) {
stateUpdate.value = ALL_VARIABLE_VALUE;
stateUpdate.text = ALL_VARIABLE_TEXT;
} else {
// Current value is not valid. Set to first of the available options
stateUpdate.value = options[0].value;
stateUpdate.text = options[0].label;
}
}
}
// Remember current value and text
const { value: prevValue, text: prevText } = this.state;
// Perform state change
this.setStateHelper(stateUpdate);
// Publish value changed event only if value changed
if (stateUpdate.value !== prevValue || stateUpdate.text !== prevText || this.hasAllValue()) {
this.publishEvent(new SceneVariableValueChangedEvent(this), true);
}
}
public getValue(): VariableValue {
if (this.hasAllValue()) {
if (this.state.allValue) {
return new CustomAllValue(this.state.allValue);
}
return this.state.options.map((x) => x.value);
}
return this.state.value;
}
public getValueText(): string {
if (this.hasAllValue()) {
return ALL_VARIABLE_TEXT;
}
if (Array.isArray(this.state.text)) {
return this.state.text.join(' + ');
}
return String(this.state.text);
}
private hasAllValue() {
const value = this.state.value;
return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE);
}
private getDefaultMultiState(options: VariableValueOption[]) {
if (this.state.defaultToAll) {
return { value: [ALL_VARIABLE_VALUE], text: [ALL_VARIABLE_TEXT] };
} else {
return { value: [options[0].value], text: [options[0].label] };
}
}
/**
* Change the value and publish SceneVariableValueChangedEvent event
*/
public changeValueTo(value: VariableValue, text?: VariableValue) {
// Igore if there is no change
if (value === this.state.value && text === this.state.text) {
return;
}
if (!text) {
if (Array.isArray(value)) {
text = value.map((v) => this.findLabelTextForValue(v));
} else {
text = this.findLabelTextForValue(value);
}
}
if (Array.isArray(value)) {
// If we are a multi valued variable is cleared (empty array) we need to set the default empty state
if (value.length === 0) {
const state = this.getDefaultMultiState(this.state.options);
value = state.value;
text = state.text;
}
// If last value is the All value then replace all with it
if (value[value.length - 1] === ALL_VARIABLE_VALUE) {
value = [ALL_VARIABLE_VALUE];
text = [ALL_VARIABLE_TEXT];
}
// If the first value is the ALL value and we have other values, then remove the All value
else if (value[0] === ALL_VARIABLE_VALUE && value.length > 1) {
value.shift();
if (Array.isArray(text)) {
text.shift();
}
}
}
this.setStateHelper({ value, text, loading: false });
this.publishEvent(new SceneVariableValueChangedEvent(this), true);
}
private findLabelTextForValue(value: VariableValueSingle): VariableValueSingle {
const option = this.state.options.find((x) => x.value === value);
if (option) {
return option.label;
}
const optionByLabel = this.state.options.find((x) => x.label === value);
if (optionByLabel) {
return optionByLabel.label;
}
return value;
}
/**
* This helper function is to counter the contravariance of setState
*/
private setStateHelper(state: Partial<MultiValueVariableState>) {
const test: SceneObject<MultiValueVariableState> = this;
test.setState(state);
}
public getOptionsForSelect(): VariableValueOption[] {
let options = this.state.options;
if (this.state.includeAll) {
options = [{ value: ALL_VARIABLE_VALUE, label: ALL_VARIABLE_TEXT }, ...options];
}
if (!Array.isArray(this.state.value)) {
const current = options.find((x) => x.value === this.state.value);
if (!current) {
options = [{ value: this.state.value, label: String(this.state.text) }, ...options];
}
}
return options;
}
}
/**
* The custom allValue needs a special wrapping / handling to make it not be formatted / escaped like normal values
*/
class CustomAllValue implements VariableValueCustom {
public isCustomValue: true = true;
public constructor(private _value: string) {}
public toString() {
return this._value;
}
}
export class MultiValueUrlSyncHandler<TState extends MultiValueVariableState = MultiValueVariableState>
implements SceneObjectUrlSyncHandler<TState>
{
public constructor(private _sceneObject: MultiValueVariable<TState>) {}
private getKey(): string {
return `var-${this._sceneObject.state.name}`;
}
public getKeys(): string[] {
return [this.getKey()];
}
public getUrlState(state: TState): SceneObjectUrlValues {
let urlValue: string | string[] | null = null;
let value = state.value;
if (Array.isArray(value)) {
urlValue = value.map(String);
} else {
urlValue = String(value);
}
return { [this.getKey()]: urlValue };
}
public updateFromUrl(values: SceneObjectUrlValues): void {
const urlValue = values[this.getKey()];
if (urlValue != null) {
this._sceneObject.changeValueTo(urlValue);
}
}
}

View File

@@ -1,19 +0,0 @@
import { ObjectVariable } from './ObjectVariable';
describe('ObjectVariable', () => {
describe('getValue', () => {
it('it should return value according to fieldPath', () => {
const variable = new ObjectVariable({
name: 'test',
type: 'custom',
value: {
field1: 'value1',
array: ['value1', 'value2', 'value3'],
},
});
expect(variable.getValue('field1')).toBe('value1');
expect(variable.getValue('array[1]')).toBe('value2');
});
});
});

View File

@@ -1,32 +0,0 @@
import { property } from 'lodash';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneVariable, SceneVariableState, VariableValue } from '../types';
export interface ObjectVariableState<T extends object> extends SceneVariableState {
value: T;
}
export class ObjectVariable<T extends object>
extends SceneObjectBase<ObjectVariableState<T>>
implements SceneVariable<ObjectVariableState<T>>
{
private static fieldAccessorCache: FieldAccessorCache = {};
public getValue(fieldPath: string): VariableValue {
return this.getFieldAccessor(fieldPath)(this.state.value);
}
private getFieldAccessor(fieldPath: string) {
const accessor = ObjectVariable.fieldAccessorCache[fieldPath];
if (accessor) {
return accessor;
}
return (ObjectVariable.fieldAccessorCache[fieldPath] = property(fieldPath));
}
}
interface FieldAccessorCache {
[key: string]: (obj: any) => any;
}

View File

@@ -1,93 +0,0 @@
import { Observable, Subject } from 'rxjs';
import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree';
import { sceneGraph } from '../../core/sceneGraph';
import { SceneComponentProps } from '../../core/types';
import { VariableDependencyConfig } from '../VariableDependencyConfig';
import { renderSelectForVariable } from '../components/VariableValueSelect';
import { VariableValueOption } from '../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable';
export interface TestVariableState extends MultiValueVariableState {
query: string;
delayMs?: number;
issuedQuery?: string;
}
/**
* This variable is only designed for unit tests and potentially e2e tests.
*/
export class TestVariable extends MultiValueVariable<TestVariableState> {
private completeUpdate = new Subject<number>();
public isGettingValues = true;
public getValueOptionsCount = 0;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['query'],
});
public constructor(initialState: Partial<TestVariableState>) {
super({
type: 'custom',
name: 'Test',
value: 'Value',
text: 'Text',
query: 'Query',
options: [],
...initialState,
});
}
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
const { delayMs } = this.state;
this.getValueOptionsCount += 1;
return new Observable<VariableValueOption[]>((observer) => {
this.setState({ loading: true });
const sub = this.completeUpdate.subscribe({
next: () => {
observer.next(this.issueQuery());
},
});
let timeout: NodeJS.Timeout | undefined;
if (delayMs) {
timeout = setTimeout(() => this.signalUpdateCompleted(), delayMs);
}
this.isGettingValues = true;
return () => {
sub.unsubscribe();
clearTimeout(timeout);
this.isGettingValues = false;
};
});
}
private issueQuery() {
const interpolatedQuery = sceneGraph.interpolate(this, this.state.query);
const options = queryMetricTree(interpolatedQuery).map((x) => ({ label: x.name, value: x.name }));
this.setState({
issuedQuery: interpolatedQuery,
options,
});
return options;
}
/** Useful from tests */
public signalUpdateCompleted() {
this.completeUpdate.next(1);
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return renderSelectForVariable(model);
};
}

View File

@@ -1,310 +0,0 @@
import { lastValueFrom, of } from 'rxjs';
import {
DataQueryRequest,
DataSourceApi,
DataSourceRef,
FieldType,
getDefaultTimeRange,
LoadingState,
PanelData,
PluginType,
StandardVariableSupport,
toDataFrame,
toUtc,
VariableRefresh,
VariableSupportType,
} from '@grafana/data';
import { SceneFlexLayout } from 'app/features/scenes/components';
import { SceneTimeRange } from 'app/features/scenes/core/SceneTimeRange';
import { SceneVariableSet } from '../../sets/SceneVariableSet';
import { QueryVariable } from './QueryVariable';
import { QueryRunner, RunnerArgs, setCreateQueryVariableRunnerFactory } from './createQueryVariableRunner';
const runRequestMock = jest.fn().mockReturnValue(
of<PanelData>({
state: LoadingState.Done,
series: [
toDataFrame({
fields: [{ name: 'text', type: FieldType.string, values: ['A', 'AB', 'C'] }],
}),
],
timeRange: getDefaultTimeRange(),
})
);
const fakeDsMock: DataSourceApi = {
name: 'fake-std',
type: 'fake-std',
getRef: () => ({ type: 'fake-std', uid: 'fake-std' }),
query: () =>
Promise.resolve({
data: [],
}),
testDatasource: () => Promise.resolve({ status: 'success' }),
meta: {
id: 'fake-std',
type: PluginType.datasource,
module: 'fake-std',
baseUrl: '',
name: 'fake-std',
info: {
author: { name: '' },
description: '',
links: [],
logos: { large: '', small: '' },
updated: '',
version: '',
screenshots: [],
},
},
// Standard variable support
variables: {
getType: () => VariableSupportType.Standard,
toDataQuery: (q) => ({ ...q, refId: 'FakeDataSource-refId' }),
},
id: 1,
uid: 'fake-std',
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
get: (ds: DataSourceRef): Promise<DataSourceApi> => {
return Promise.resolve(fakeDsMock);
},
}),
}));
class FakeQueryRunner implements QueryRunner {
public constructor(private datasource: DataSourceApi, private _runRequest: jest.Mock) {}
public getTarget(variable: QueryVariable) {
return (this.datasource.variables as StandardVariableSupport<DataSourceApi>).toDataQuery(variable.state.query);
}
public runRequest(args: RunnerArgs, request: DataQueryRequest) {
return this._runRequest(
this.datasource,
request,
(this.datasource.variables as StandardVariableSupport<DataSourceApi>).query
);
}
}
describe('QueryVariable', () => {
describe('When empty query is provided', () => {
it('Should default to empty options and empty value', async () => {
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake', type: 'fake' },
query: '',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('');
expect(variable.state.text).toEqual('');
expect(variable.state.options).toEqual([]);
});
});
describe('When no data source is provided', () => {
it('Should default to empty options and empty value', async () => {
const variable = new QueryVariable({
name: 'test',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.value).toEqual('');
expect(variable.state.text).toEqual('');
expect(variable.state.options).toEqual([]);
});
});
describe('Issuing variable query', () => {
const originalNow = Date.now;
beforeEach(() => {
setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock));
});
beforeEach(() => {
Date.now = jest.fn(() => 60000);
});
afterEach(() => {
Date.now = originalNow;
runRequestMock.mockClear();
});
it('Should resolve variable options via provided runner', async () => {
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.options).toEqual([
{ label: 'A', value: 'A' },
{ label: 'AB', value: 'AB' },
{ label: 'C', value: 'C' },
]);
});
it('Should pass variable scene object via request scoped vars', async () => {
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
});
await lastValueFrom(variable.validateAndUpdate());
const call = runRequestMock.mock.calls[0];
expect(call[1].scopedVars.__sceneObject).toEqual({ value: variable, text: '__sceneObject' });
});
describe('when refresh on dashboard load set', () => {
it('Should issue variable query with default time range', async () => {
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
});
await lastValueFrom(variable.validateAndUpdate());
expect(runRequestMock).toBeCalledTimes(1);
const call = runRequestMock.mock.calls[0];
expect(call[1].range).toEqual(getDefaultTimeRange());
});
it('Should not issue variable query when the closest time range changes if refresh on dahboard load is set', async () => {
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' });
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
refresh: VariableRefresh.onDashboardLoad,
$timeRange: timeRange,
});
variable.activate();
await lastValueFrom(variable.validateAndUpdate());
expect(runRequestMock).toBeCalledTimes(1);
const call1 = runRequestMock.mock.calls[0];
// Uses default time range
expect(call1[1].range.raw).toEqual({
from: 'now-6h',
to: 'now',
});
timeRange.onTimeRangeChange({
from: toUtc('2020-01-01'),
to: toUtc('2020-01-02'),
raw: { from: toUtc('2020-01-01'), to: toUtc('2020-01-02') },
});
await Promise.resolve();
expect(runRequestMock).toBeCalledTimes(1);
});
});
describe('when refresh on time range change set', () => {
it('Should issue variable query with closes time range if refresh on time range change set', async () => {
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
refresh: VariableRefresh.onTimeRangeChanged,
});
// @ts-expect-error
const scene = new SceneFlexLayout({
$timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [variable],
}),
children: [],
});
await lastValueFrom(variable.validateAndUpdate());
expect(runRequestMock).toBeCalledTimes(1);
const call = runRequestMock.mock.calls[0];
expect(call[1].range.raw).toEqual({
from: 'now-1h',
to: 'now',
});
});
it('Should issue variable query when time range changes if refresh on time range change is set', async () => {
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' });
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
refresh: VariableRefresh.onTimeRangeChanged,
$timeRange: timeRange,
});
variable.activate();
await lastValueFrom(variable.validateAndUpdate());
expect(runRequestMock).toBeCalledTimes(1);
const call1 = runRequestMock.mock.calls[0];
expect(call1[1].range.raw).toEqual({
from: 'now-1h',
to: 'now',
});
timeRange.onTimeRangeChange({
from: toUtc('2020-01-01'),
to: toUtc('2020-01-02'),
raw: { from: toUtc('2020-01-01'), to: toUtc('2020-01-02') },
});
await new Promise((r) => setTimeout(r, 1));
expect(runRequestMock).toBeCalledTimes(2);
const call2 = runRequestMock.mock.calls[1];
expect(call2[1].range.raw).toEqual({
from: '2020-01-01T00:00:00.000Z',
to: '2020-01-02T00:00:00.000Z',
});
});
});
});
describe('When regex provided', () => {
beforeEach(() => {
setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock));
});
it('should return options that match regex', async () => {
const variable = new QueryVariable({
name: 'test',
datasource: { uid: 'fake-std', type: 'fake-std' },
query: 'query',
regex: '/^A/',
});
await lastValueFrom(variable.validateAndUpdate());
expect(variable.state.options).toEqual([
{ label: 'A', value: 'A' },
{ label: 'AB', value: 'AB' },
]);
});
});
});

View File

@@ -1,166 +0,0 @@
import React from 'react';
import { Observable, Subject, of, Unsubscribable, filter, take, mergeMap, catchError, throwError, from } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import {
CoreApp,
DataQuery,
DataQueryRequest,
DataSourceApi,
DataSourceRef,
getDefaultTimeRange,
LoadingState,
PanelData,
ScopedVars,
VariableRefresh,
VariableSort,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { toMetricFindValues } from 'app/features/variables/query/operators';
import { sceneGraph } from '../../../core/sceneGraph';
import { SceneComponentProps } from '../../../core/types';
import { VariableDependencyConfig } from '../../VariableDependencyConfig';
import { VariableValueSelect } from '../../components/VariableValueSelect';
import { VariableValueOption } from '../../types';
import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../MultiValueVariable';
import { createQueryVariableRunner } from './createQueryVariableRunner';
import { metricNamesToVariableValues } from './utils';
export interface QueryVariableState extends MultiValueVariableState {
type: 'query';
datasource: DataSourceRef | null;
query: any;
regex: string;
refresh: VariableRefresh;
sort: VariableSort;
}
export class QueryVariable extends MultiValueVariable<QueryVariableState> {
private updateSubscription?: Unsubscribable;
private dataSourceSubject?: Subject<DataSourceApi>;
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['regex', 'query', 'datasource'],
});
public constructor(initialState: Partial<QueryVariableState>) {
super({
type: 'query',
name: '',
value: '',
text: '',
query: '',
options: [],
datasource: null,
regex: '',
refresh: VariableRefresh.onDashboardLoad,
sort: VariableSort.alphabeticalAsc,
...initialState,
});
}
public activate(): void {
super.activate();
const timeRange = sceneGraph.getTimeRange(this);
if (this.state.refresh === VariableRefresh.onTimeRangeChanged) {
this._subs.add(
timeRange.subscribeToState({
next: () => {
this.updateSubscription = this.validateAndUpdate().subscribe();
},
})
);
}
}
public deactivate(): void {
super.deactivate();
if (this.updateSubscription) {
this.updateSubscription.unsubscribe();
}
if (this.dataSourceSubject) {
this.dataSourceSubject.unsubscribe();
}
}
public getValueOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
if (this.state.query === '' || !this.state.datasource) {
return of([]);
}
return from(this.getDataSource()).pipe(
mergeMap((ds) => {
const runner = createQueryVariableRunner(ds);
const target = runner.getTarget(this);
const request = this.getRequest(target);
return runner.runRequest({ variable: this }, request).pipe(
filter((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), // we only care about done or error for now
take(1), // take the first result, using first caused a bug where it in some situations throw an uncaught error because of no results had been received yet
mergeMap((data: PanelData) => {
if (data.state === LoadingState.Error) {
return throwError(() => data.error);
}
return of(data);
}),
toMetricFindValues(),
mergeMap((values) => {
let regex = '';
if (this.state.regex) {
regex = sceneGraph.interpolate(this, this.state.regex, undefined, 'regex');
}
return of(metricNamesToVariableValues(regex, this.state.sort, values));
}),
catchError((error) => {
if (error.cancelled) {
return of([]);
}
return throwError(() => error);
})
);
})
);
}
private async getDataSource(): Promise<DataSourceApi> {
return getDataSourceSrv().get(this.state.datasource, {
__sceneObject: { text: '__sceneObject', value: this },
});
}
private getRequest(target: DataQuery) {
// TODO: add support for search filter
// const { searchFilter } = this.state.searchFilter;
// const searchFilterScope = { searchFilter: { text: searchFilter, value: searchFilter } };
// const searchFilterAsVars = searchFilter ? searchFilterScope : {};
const scopedVars: ScopedVars = {
// ...searchFilterAsVars,
__sceneObject: { text: '__sceneObject', value: this },
};
const range =
this.state.refresh === VariableRefresh.onTimeRangeChanged
? sceneGraph.getTimeRange(this).state.value
: getDefaultTimeRange();
const request: DataQueryRequest = {
app: CoreApp.Dashboard,
requestId: uuidv4(),
timezone: '',
range,
interval: '',
intervalMs: 0,
targets: [target],
scopedVars,
startTime: Date.now(),
};
return request;
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable>) => {
return <VariableValueSelect model={model} />;
};
}

View File

@@ -1,115 +0,0 @@
import { from, mergeMap, Observable, of } from 'rxjs';
import {
DataQuery,
DataQueryRequest,
DataSourceApi,
getDefaultTimeRange,
LoadingState,
PanelData,
} from '@grafana/data';
import { runRequest } from 'app/features/query/state/runRequest';
import { hasLegacyVariableSupport, hasStandardVariableSupport } from 'app/features/variables/guard';
import { QueryVariable } from './QueryVariable';
export interface RunnerArgs {
searchFilter?: string;
variable: QueryVariable;
}
export interface QueryRunner {
getTarget: (variable: QueryVariable) => DataQuery;
runRequest: (args: RunnerArgs, request: DataQueryRequest) => Observable<PanelData>;
}
class StandardQueryRunner implements QueryRunner {
public constructor(private datasource: DataSourceApi, private _runRequest = runRequest) {}
public getTarget(variable: QueryVariable) {
if (hasStandardVariableSupport(this.datasource)) {
return this.datasource.variables.toDataQuery(variable.state.query);
}
throw new Error("Couldn't create a target with supplied arguments.");
}
public runRequest(_: RunnerArgs, request: DataQueryRequest) {
if (!hasStandardVariableSupport(this.datasource)) {
return getEmptyMetricFindValueObservable();
}
if (!this.datasource.variables.query) {
return this._runRequest(this.datasource, request);
}
return this._runRequest(this.datasource, request, this.datasource.variables.query);
}
}
class LegacyQueryRunner implements QueryRunner {
public constructor(private datasource: DataSourceApi) {}
public getTarget(variable: QueryVariable) {
if (hasLegacyVariableSupport(this.datasource)) {
return variable.state.query;
}
throw new Error("Couldn't create a target with supplied arguments.");
}
public runRequest({ variable }: RunnerArgs, request: DataQueryRequest) {
if (!hasLegacyVariableSupport(this.datasource)) {
return getEmptyMetricFindValueObservable();
}
return from(
this.datasource.metricFindQuery(variable.state.query, {
...request,
// variable is used by SQL common data source
variable: {
name: variable.state.name,
type: variable.state.type,
},
// TODO: add support for search filter
// searchFilter
})
).pipe(
mergeMap((values) => {
if (!values || !values.length) {
return getEmptyMetricFindValueObservable();
}
const series: any = values;
return of({ series, state: LoadingState.Done, timeRange: request.range });
})
);
}
}
function getEmptyMetricFindValueObservable(): Observable<PanelData> {
return of({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() });
}
function createQueryVariableRunnerFactory(datasource: DataSourceApi): QueryRunner {
if (hasStandardVariableSupport(datasource)) {
return new StandardQueryRunner(datasource, runRequest);
}
if (hasLegacyVariableSupport(datasource)) {
return new LegacyQueryRunner(datasource);
}
// TODO: add support for legacy, cutom and datasource query runners
throw new Error(`Couldn't create a query runner for datasource ${datasource.type}`);
}
export let createQueryVariableRunner = createQueryVariableRunnerFactory;
/**
* Use only in tests
*/
export function setCreateQueryVariableRunnerFactory(fn: (datasource: DataSourceApi) => QueryRunner) {
createQueryVariableRunner = fn;
}

View File

@@ -1,111 +0,0 @@
import { isNumber, sortBy, toLower, uniqBy } from 'lodash';
import { stringToJsRegex, VariableSort } from '@grafana/data';
import { VariableValueOption } from '../../types';
export const metricNamesToVariableValues = (variableRegEx: string, sort: VariableSort, metricNames: any[]) => {
let regex;
let options: VariableValueOption[] = [];
if (variableRegEx) {
regex = stringToJsRegex(variableRegEx);
}
for (let i = 0; i < metricNames.length; i++) {
const item = metricNames[i];
let text = item.text === undefined || item.text === null ? item.value : item.text;
let value = item.value === undefined || item.value === null ? item.text : item.value;
if (isNumber(value)) {
value = value.toString();
}
if (isNumber(text)) {
text = text.toString();
}
if (regex) {
const matches = getAllMatches(value, regex);
if (!matches.length) {
continue;
}
const valueGroup = matches.find((m) => m.groups && m.groups.value);
const textGroup = matches.find((m) => m.groups && m.groups.text);
const firstMatch = matches.find((m) => m.length > 1);
const manyMatches = matches.length > 1 && firstMatch;
if (valueGroup || textGroup) {
value = valueGroup?.groups?.value ?? textGroup?.groups?.text;
text = textGroup?.groups?.text ?? valueGroup?.groups?.value;
} else if (manyMatches) {
for (let j = 0; j < matches.length; j++) {
const match = matches[j];
options.push({ label: match[1], value: match[1] });
}
continue;
} else if (firstMatch) {
text = firstMatch[1];
value = firstMatch[1];
}
}
options.push({ label: text, value: value });
}
options = uniqBy(options, 'value');
return sortVariableValues(options, sort);
};
const getAllMatches = (str: string, regex: RegExp): RegExpExecArray[] => {
const results: RegExpExecArray[] = [];
let matches = null;
regex.lastIndex = 0;
do {
matches = regex.exec(str);
if (matches) {
results.push(matches);
}
} while (regex.global && matches && matches[0] !== '' && matches[0] !== undefined);
return results;
};
export const sortVariableValues = (options: any[], sortOrder: VariableSort) => {
if (sortOrder === VariableSort.disabled) {
return options;
}
const sortType = Math.ceil(sortOrder / 2);
const reverseSort = sortOrder % 2 === 0;
if (sortType === 1) {
options = sortBy(options, 'text');
} else if (sortType === 2) {
options = sortBy(options, (opt) => {
if (!opt.text) {
return -1;
}
const matches = opt.text.match(/.*?(\d+).*/);
if (!matches || matches.length < 2) {
return -1;
} else {
return parseInt(matches[1], 10);
}
});
} else if (sortType === 3) {
options = sortBy(options, (opt) => {
return toLower(opt.text);
});
}
if (reverseSort) {
options = options.reverse();
}
return options;
};

View File

@@ -1,7 +1,6 @@
import { VariableValue, FormatVariable } from '@grafana/scenes';
import { VariableModel, VariableType } from '@grafana/schema';
import { FormatVariable } from '../scenes/variables/interpolation/formatRegistry';
import { VariableValue } from '../scenes/variables/types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants';
export class LegacyVariableWrapper implements FormatVariable {

View File

@@ -1,11 +1,10 @@
import { dateTime, TimeRange } from '@grafana/data';
import { setDataSourceSrv } from '@grafana/runtime';
import { FormatRegistryID, TestVariable } from '@grafana/scenes';
import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput';
import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv';
import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks';
import { FormatRegistryID } from '../scenes/variables/interpolation/formatRegistry';
import { TestVariable } from '../scenes/variables/variants/TestVariable';
import { VariableAdapter, variableAdapters } from '../variables/adapters';
import { createAdHocVariableAdapter } from '../variables/adhoc/adapter';
import { createQueryVariableAdapter } from '../variables/query/adapter';
@@ -20,7 +19,8 @@ variableAdapters.setInit(() => [
const interpolateMock = jest.fn();
jest.mock('../scenes/core/sceneGraph', () => ({
jest.mock('@grafana/scenes', () => ({
...jest.requireActual('@grafana/scenes'),
sceneGraph: {
interpolate: (...args: any[]) => interpolateMock(...args),
},

View File

@@ -9,11 +9,8 @@ import {
TypedVariableModel,
} from '@grafana/data';
import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime';
import { SceneObjectBase, sceneGraph, FormatRegistryID, formatRegistry, CustomFormatterFn } from '@grafana/scenes';
import { SceneObjectBase } from '../scenes/core/SceneObjectBase';
import { sceneGraph } from '../scenes/core/sceneGraph';
import { formatRegistry, FormatRegistryID } from '../scenes/variables/interpolation/formatRegistry';
import { CustomFormatterFn } from '../scenes/variables/interpolation/sceneInterpolator';
import { variableAdapters } from '../variables/adapters';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants';
import { isAdHoc } from '../variables/guard';