mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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'
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SceneFlexLayout } from '@grafana/scenes';
|
||||
|
||||
import { Scene } from './Scene';
|
||||
import { SceneFlexLayout } from './layout/SceneFlexLayout';
|
||||
|
||||
describe('Scene', () => {
|
||||
it('Simple scene', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })));
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
};
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
'<script>alert(asd)</script>'
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user