diff --git a/.betterer.results b/.betterer.results index bfee741b724..2d243cd67e2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4380,73 +4380,10 @@ exports[`better eslint`] = { "public/app/features/sandbox/TestStuffPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], - "public/app/features/scenes/components/layout/SceneFlexLayout.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/scenes/core/SceneComponentWrapper.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/features/scenes/core/SceneObjectBase.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/scenes/core/SceneObjectBase.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], - "public/app/features/scenes/core/sceneGraph.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/scenes/core/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/scenes/core/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "public/app/features/scenes/editor/SceneObjectTree.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/scenes/querying/SceneQueryRunner.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"] - ], - "public/app/features/scenes/variables/interpolation/sceneInterpolator.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], - "public/app/features/scenes/variables/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/scenes/variables/variants/ObjectVariable.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/features/scenes/variables/variants/query/QueryVariable.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/scenes/variables/variants/query/createQueryVariableRunner.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/scenes/variables/variants/query/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/search/components/SearchCard.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/.yarnrc.yml b/.yarnrc.yml index d6e62c457a7..dca84a2e686 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -60,3 +60,10 @@ plugins: spec: 'https://mskelton.dev/yarn-outdated/v2' yarnPath: .yarn/releases/yarn-3.3.0.cjs +# Uncomment the following lines if you want to use Verdaccio local npm registry. Read more at packages/README.md +# npmScopes: +# grafana: +# npmRegistryServer: http://localhost:4873 + +# unsafeHttpWhitelist: +# - 'localhost' diff --git a/package.json b/package.json index a8298234a82..fc7594e8bb4 100644 --- a/package.json +++ b/package.json @@ -253,6 +253,7 @@ "@grafana/lezer-logql": "0.1.1", "@grafana/monaco-logql": "^0.0.6", "@grafana/runtime": "workspace:*", + "@grafana/scenes": "0.0.4", "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", "@jaegertracing/jaeger-ui-components": "workspace:*", @@ -428,5 +429,10 @@ "engines": { "node": ">= 16" }, - "packageManager": "yarn@3.3.0" + "packageManager": "yarn@3.3.0", + "dependenciesMeta": { + "@grafana/scenes@0.0.3": { + "unplugged": true + } + } } diff --git a/public/app/features/scenes/components/VizPanel/panelBuilders.ts b/public/app/features/scenes/builders/panelBuilders.ts similarity index 94% rename from public/app/features/scenes/components/VizPanel/panelBuilders.ts rename to public/app/features/scenes/builders/panelBuilders.ts index 5fb453715c2..d0c1247148d 100644 --- a/public/app/features/scenes/components/VizPanel/panelBuilders.ts +++ b/public/app/features/scenes/builders/panelBuilders.ts @@ -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 = Omit< Partial>, 'pluginId' diff --git a/public/app/features/scenes/components/NestedScene.test.tsx b/public/app/features/scenes/components/NestedScene.test.tsx deleted file mode 100644 index 2778d7c7939..00000000000 --- a/public/app/features/scenes/components/NestedScene.test.tsx +++ /dev/null @@ -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( - - - - ); -} - -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(); - }); -}); diff --git a/public/app/features/scenes/components/NestedScene.tsx b/public/app/features/scenes/components/NestedScene.tsx deleted file mode 100644 index e340dacde83..00000000000 --- a/public/app/features/scenes/components/NestedScene.tsx +++ /dev/null @@ -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 { - 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) { - const { title, isCollapsed, canCollapse, canRemove, body, actions } = model.useState(); - const styles = useStyles2(getStyles); - - const toolbarActions = (actions ?? []).map((action) => ); - - if (canRemove) { - toolbarActions.push( - - ); - } - - return ( -
-
- -
- {title} -
- {canCollapse && ( -
-
- )} -
-
{toolbarActions}
-
- {!isCollapsed && } -
- ); -} - -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, - }), -}); diff --git a/public/app/features/scenes/components/Scene.test.tsx b/public/app/features/scenes/components/Scene.test.tsx index 761dc1cebe2..b6138fca279 100644 --- a/public/app/features/scenes/components/Scene.test.tsx +++ b/public/app/features/scenes/components/Scene.test.tsx @@ -1,5 +1,6 @@ +import { SceneFlexLayout } from '@grafana/scenes'; + import { Scene } from './Scene'; -import { SceneFlexLayout } from './layout/SceneFlexLayout'; describe('Scene', () => { it('Simple scene', () => { diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx index f1c7397568d..18857af0438 100644 --- a/public/app/features/scenes/components/Scene.tsx +++ b/public/app/features/scenes/components/Scene.tsx @@ -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 { public static Component = SceneRenderer; private urlSyncManager?: UrlSyncManager; @@ -34,30 +23,6 @@ export class Scene extends SceneObjectBase { } } -export class EmbeddedScene extends Scene { - public static Component = EmbeddedSceneRenderer; -} - -function EmbeddedSceneRenderer({ model }: SceneComponentProps) { - const { body, isEditing, subMenu } = model.useState(); - return ( -
- {subMenu && } -
- -
-
- ); -} function SceneRenderer({ model }: SceneComponentProps) { const { title, body, actions = [], isEditing, $editor, subMenu } = model.useState(); diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx deleted file mode 100644 index 6552a45b28a..00000000000 --- a/public/app/features/scenes/components/SceneCanvasText.tsx +++ /dev/null @@ -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 { - public static Editor = Editor; - - protected _variableDependency = new VariableDependencyConfig(this, { statePaths: ['text'] }); - - public static Component = ({ model }: SceneComponentProps) => { - 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 ( -
- {sceneGraph.interpolate(model, text)} -
- ); - }; -} - -function Editor({ model }: SceneComponentProps) { - const { fontSize } = model.useState(); - - return ( - - model.setState({ fontSize: parseInt(evt.currentTarget.value, 10) })} - /> - - ); -} diff --git a/public/app/features/scenes/components/SceneDragHandle.tsx b/public/app/features/scenes/components/SceneDragHandle.tsx deleted file mode 100644 index f30130b86fd..00000000000 --- a/public/app/features/scenes/components/SceneDragHandle.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -import { Icon } from '@grafana/ui'; - -export function SceneDragHandle({ layoutKey, className }: { layoutKey: string; className?: string }) { - return ( -
- -
- ); -} diff --git a/public/app/features/scenes/components/ScenePanelRepeater.tsx b/public/app/features/scenes/components/ScenePanelRepeater.tsx deleted file mode 100644 index 42a18fd8832..00000000000 --- a/public/app/features/scenes/components/ScenePanelRepeater.tsx +++ /dev/null @@ -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; -} - -export class ScenePanelRepeater extends SceneObjectBase { - 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) => { - const { layout } = model.useState(); - return ; - }; -} diff --git a/public/app/features/scenes/components/SceneSubMenu.tsx b/public/app/features/scenes/components/SceneSubMenu.tsx deleted file mode 100644 index 2563c2f2a37..00000000000 --- a/public/app/features/scenes/components/SceneSubMenu.tsx +++ /dev/null @@ -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 { - public static Component = SceneSubMenuRenderer; -} - -function SceneSubMenuRenderer({ model }: SceneComponentProps) { - const { children } = model.useState(); - - return ( -
- {children.map((child) => ( - - ))} -
- ); -} diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx deleted file mode 100644 index 2f294420b0e..00000000000 --- a/public/app/features/scenes/components/SceneTimePicker.tsx +++ /dev/null @@ -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 { - public static Component = SceneTimePickerRenderer; -} - -function SceneTimePickerRenderer({ model }: SceneComponentProps) { - const { hidePicker } = model.useState(); - const timeRange = sceneGraph.getTimeRange(model); - const timeRangeState = timeRange.useState(); - - if (hidePicker) { - return null; - } - - return ( - - {}} - onMoveForward={() => {}} - onZoom={() => {}} - onChangeTimeZone={() => {}} - onChangeFiscalYearStartMonth={() => {}} - /> - - - - ); -} diff --git a/public/app/features/scenes/components/SceneToolbarButton.tsx b/public/app/features/scenes/components/SceneToolbarButton.tsx deleted file mode 100644 index abe4ed53298..00000000000 --- a/public/app/features/scenes/components/SceneToolbarButton.tsx +++ /dev/null @@ -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 { - public static Component = ({ model }: SceneComponentProps) => { - const state = model.useState(); - - return ; - }; -} - -export interface SceneToolbarInputState extends SceneObjectStatePlain { - value?: string; - onChange: (value: number) => void; -} - -export class SceneToolbarInput extends SceneObjectBase { - public static Component = ({ model }: SceneComponentProps) => { - const state = model.useState(); - - return ( - { - model.state.onChange(parseInt(evt.currentTarget.value, 10)); - }} - /> - ); - }; -} diff --git a/public/app/features/scenes/components/VizPanel/VizPanel.test.tsx b/public/app/features/scenes/components/VizPanel/VizPanel.test.tsx deleted file mode 100644 index 75fce31a2be..00000000000 --- a/public/app/features/scenes/components/VizPanel/VizPanel.test.tsx +++ /dev/null @@ -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', - }, - () =>
My custom panel
- ); - - 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; - - beforeAll(async () => { - panel = new VizPanel({ - 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; - - beforeAll(async () => { - panel = new VizPanel({ 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); - }); - }); -}); diff --git a/public/app/features/scenes/components/VizPanel/VizPanel.tsx b/public/app/features/scenes/components/VizPanel/VizPanel.tsx deleted file mode 100644 index 58a8e9f772f..00000000000 --- a/public/app/features/scenes/components/VizPanel/VizPanel.tsx +++ /dev/null @@ -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 extends SceneLayoutChildState { - title: string; - pluginId: string; - options: DeepPartial; - fieldConfig: FieldConfigSource>; - pluginVersion?: string; - // internal state - pluginLoadError?: string; -} - -export class VizPanel extends SceneObjectBase> { - 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>) { - 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) => { - this.setState({ fieldConfig }); - }; -} - -function VizPanelEditor({ model }: SceneComponentProps) { - const { title } = model.useState(); - - return ( - - model.setState({ title: evt.currentTarget.value })} /> - - ); -} diff --git a/public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx b/public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx deleted file mode 100644 index 9b418f3926a..00000000000 --- a/public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx +++ /dev/null @@ -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) { - 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 = ; - - 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
Failed to load plugin: {pluginLoadError}
; - } - - if (!plugin || !plugin.hasPluginId(pluginId)) { - return
Loading plugin panel...
; - } - - if (!plugin.panel) { - return
Panel plugin has no panel component
; - } - - const PanelComponent = plugin.panel; - - // Query runner needs to with for auto maxDataPoints - if ($data instanceof SceneQueryRunner) { - $data.setContainerWidth(width); - } - - return ( -
} style={{ position: 'absolute', width: '100%', height: '100%' }}> - - {(innerWidth, innerHeight) => ( - <> - {!dataWithOverrides &&
No data...
} - {dataWithOverrides && ( - - - - sceneGraph.interpolate(model, str, scopedVars, format as string | CustomFormatterFn | undefined) - } - onOptionsChange={model.onOptionsChange} - onFieldConfigChange={model.onFieldConfigChange} - onChangeTimeRange={model.onChangeTimeRange} - eventBus={appEvents} - /> - - - )} - - )} -
-
- ); -} - -VizPanelRenderer.displayName = 'ScenePanelRenderer'; diff --git a/public/app/features/scenes/components/index.ts b/public/app/features/scenes/components/index.ts deleted file mode 100644 index c99ebfbf789..00000000000 --- a/public/app/features/scenes/components/index.ts +++ /dev/null @@ -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'; diff --git a/public/app/features/scenes/components/layout/SceneFlexLayout.tsx b/public/app/features/scenes/components/layout/SceneFlexLayout.tsx deleted file mode 100644 index e8803d87262..00000000000 --- a/public/app/features/scenes/components/layout/SceneFlexLayout.tsx +++ /dev/null @@ -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 { - public static Component = FlexLayoutRenderer; - public static Editor = FlexLayoutEditor; - - public toggleDirection() { - this.setState({ - direction: this.state.direction === 'row' ? 'column' : 'row', - }); - } -} - -function FlexLayoutRenderer({ model, isEditing }: SceneComponentProps) { - const { direction = 'row', children } = model.useState(); - - return ( -
- {children.map((item) => ( - - ))} -
- ); -} - -function FlexLayoutChildComponent({ - item, - direction, - isEditing, -}: { - item: SceneLayoutChild; - direction: FlexLayoutDirection; - isEditing?: boolean; -}) { - const { placement } = item.useState(); - - return ( -
- -
- ); -} - -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) { - const { direction = 'row' } = model.useState(); - const options = [ - { icon: 'arrow-right', value: 'row' }, - { icon: 'arrow-down', value: 'column' }, - ]; - - return ( - - model.setState({ direction: value as FlexLayoutDirection })} - /> - - ); -} diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx deleted file mode 100644 index da7cc343fc5..00000000000 --- a/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx +++ /dev/null @@ -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 { - public static Component = (m: SceneComponentProps) => { - return
TestObject
; - }; -} - -function renderWithProvider(element: JSX.Element) { - const store = configureStore(); - return render({element}); -} - -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(); - - 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(); - - 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(); - - 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); - }); - }); -}); diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.tsx deleted file mode 100644 index 34f0750cbfb..00000000000 --- a/public/app/features/scenes/components/layout/SceneGridLayout.tsx +++ /dev/null @@ -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 { - 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) { - const { children } = model.useState(); - validateChildrenSize(children); - - return ( - - {({ 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. - */ -
- 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 ( -
- -
- ); - })} -
-
- ); - }} -
- ); -} - -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); -} diff --git a/public/app/features/scenes/components/layout/SceneGridRow.tsx b/public/app/features/scenes/components/layout/SceneGridRow.tsx deleted file mode 100644 index 8bbd84d8ea5..00000000000 --- a/public/app/features/scenes/components/layout/SceneGridRow.tsx +++ /dev/null @@ -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>; -} - -export class SceneGridRow extends SceneObjectBase { - 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) { - const styles = useStyles2(getSceneGridRowStyles); - const { isCollapsible, isCollapsed, title, placement } = model.useState(); - const layout = sceneGraph.getLayout(model); - const dragHandle = ; - - return ( -
-
-
- {isCollapsible && } - {title} -
- {placement?.isDraggable && isCollapsed &&
{dragHandle}
} -
-
- ); -} - -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, - }), - }; -}; diff --git a/public/app/features/scenes/core/SceneComponentWrapper.tsx b/public/app/features/scenes/core/SceneComponentWrapper.tsx deleted file mode 100644 index caf04343783..00000000000 --- a/public/app/features/scenes/core/SceneComponentWrapper.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useEffect } from 'react'; - -import { SceneComponentProps, SceneEditor, SceneObject } from './types'; - -export function SceneComponentWrapper({ - model, - isEditing, - ...otherProps -}: SceneComponentProps) { - const Component = (model as any).constructor['Component'] ?? EmptyRenderer; - const inner = ; - - // 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 ( - - {inner} - - ); -} - -function EmptyRenderer(_: SceneComponentProps): 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'); -} diff --git a/public/app/features/scenes/core/SceneDataNode.ts b/public/app/features/scenes/core/SceneDataNode.ts deleted file mode 100644 index 0506317b604..00000000000 --- a/public/app/features/scenes/core/SceneDataNode.ts +++ /dev/null @@ -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 {} diff --git a/public/app/features/scenes/core/SceneDataTransformer.test.ts b/public/app/features/scenes/core/SceneDataTransformer.test.ts deleted file mode 100644 index 004343bb289..00000000000 --- a/public/app/features/scenes/core/SceneDataTransformer.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/public/app/features/scenes/core/SceneDataTransformer.ts b/public/app/features/scenes/core/SceneDataTransformer.ts deleted file mode 100644 index c1b26edf333..00000000000 --- a/public/app/features/scenes/core/SceneDataTransformer.ts +++ /dev/null @@ -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 { - 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) { - if (this._transformationsSub) { - this._transformationsSub.unsubscribe(); - this._transformationsSub = undefined; - } - - this._transformationsSub = data.pipe(getTransformationsStream(this, this.state.transformations)).subscribe({ - next: (data) => { - this.setState({ data }); - }, - }); - } -} diff --git a/public/app/features/scenes/core/SceneObjectBase.test.ts b/public/app/features/scenes/core/SceneObjectBase.test.ts deleted file mode 100644 index e36d5479388..00000000000 --- a/public/app/features/scenes/core/SceneObjectBase.test.ts +++ /dev/null @@ -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; - children?: SceneLayoutChild[]; - actions?: SceneObject[]; -} - -class TestScene extends SceneObjectBase {} - -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); - }); - }); -}); diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx deleted file mode 100644 index cf250e26be1..00000000000 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ /dev/null @@ -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 - implements SceneObject -{ - private _isActive = false; - private _subject = new Subject(); - 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 | 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 | 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 { - return SceneComponentWrapper; - } - - /** - * Temporary solution, should be replaced by declarative options - */ - public get Editor(): SceneComponent { - return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent; - } - - private setParent() { - forEachSceneObjectInState(this._state, (child) => (child._parent = this)); - } - - /** - * Subscribe to the scene state subject - **/ - public subscribeToState(observerOrNext?: Partial>): Subscription { - return this._subject.subscribe(observerOrNext); - } - - /** - * Subscribe to the scene event - **/ - public subscribeToEvent(eventType: BusEventType, handler: BusEventHandler): Unsubscribable { - return this._events.subscribe(eventType, handler); - } - - public setState(update: Partial) { - 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(); - } - - /** - * 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): 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(model: SceneObjectBase): TState { - const forceUpdate = useForceUpdate(); - - useEffect(() => { - const s = model.subscribeToState({ next: forceUpdate }); - return () => s.unsubscribe(); - }, [model, forceUpdate]); - - return model.state; -} diff --git a/public/app/features/scenes/core/SceneTimeRange.test.tsx b/public/app/features/scenes/core/SceneTimeRange.test.tsx deleted file mode 100644 index aba98e42832..00000000000 --- a/public/app/features/scenes/core/SceneTimeRange.test.tsx +++ /dev/null @@ -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); - }); -}); diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx deleted file mode 100644 index 272ac46c6af..00000000000 --- a/public/app/features/scenes/core/SceneTimeRange.tsx +++ /dev/null @@ -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 implements SceneTimeRangeLike { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to'] }); - - public constructor(state: Partial = {}) { - 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 = {}; - - 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 = {}; - - 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, - }, - }; -} diff --git a/public/app/features/scenes/core/events.ts b/public/app/features/scenes/core/events.ts deleted file mode 100644 index 6d9347b8404..00000000000 --- a/public/app/features/scenes/core/events.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BusEventWithPayload } from '@grafana/data'; - -import { SceneObject, SceneObjectState } from './types'; - -export interface SceneObjectStateChangedPayload { - prevState: SceneObjectState; - newState: SceneObjectState; - partialUpdate: Partial; - changedObject: SceneObject; -} - -export class SceneObjectStateChangedEvent extends BusEventWithPayload { - public static readonly type = 'scene-object-state-change'; -} diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts deleted file mode 100644 index eb7279a1c68..00000000000 --- a/public/app/features/scenes/core/sceneGraph.ts +++ /dev/null @@ -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 { - 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 { - if (scene.constructor.name === 'SceneFlexLayout' || scene.constructor.name === 'SceneGridLayout') { - return scene as SceneObject; - } - - 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, -}; diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts deleted file mode 100644 index 7f179ba3bc3..00000000000 --- a/public/app/features/scenes/core/types.ts +++ /dev/null @@ -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; - $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 { - model: T; - isEditing?: boolean; -} - -export type SceneComponent = React.FunctionComponent>; - -export interface SceneDataState extends SceneObjectStatePlain { - data?: PanelData; -} - -export interface SceneObject { - /** 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; - - /** Subscribe to state changes */ - subscribeToState(observer?: Partial>): Subscription; - - /** Subscribe to a scene event */ - subscribeToEvent(typeFilter: BusEventType, handler: BusEventHandler): 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): 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): this; - - /** A React component to use for rendering the object */ - Component(props: SceneComponentProps>): React.ReactElement | null; - - /** To be replaced by declarative method */ - Editor(props: SceneComponentProps>): React.ReactElement | null; - - /** Force a re-render, should only be needed when variable values change */ - forceRender(): void; -} - -export type SceneLayoutChild = SceneObject; - -export interface SceneLayoutState extends SceneLayoutChildState { - children: SceneLayoutChild[]; -} - -export type SceneLayout = SceneObject; - -export interface SceneEditorState extends SceneObjectStatePlain { - hoverObject?: SceneObjectRef; - selectedObject?: SceneObjectRef; -} - -export interface SceneEditor extends SceneObject { - onMouseEnterObject(model: SceneObject): void; - onMouseLeaveObject(model: SceneObject): void; - onSelectObject(model: SceneObject): void; - getEditComponentWrapper(): React.ComponentType; -} - -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 { - 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 extends SceneObject { - getUrlState(state: TState): SceneObjectUrlValues; - updateFromUrl(values: SceneObjectUrlValues): void; -} - -export interface SceneObjectUrlSyncHandler { - getKeys(): string[]; - getUrlState(state: TState): SceneObjectUrlValues; - updateFromUrl(values: SceneObjectUrlValues): void; -} - -export type SceneObjectUrlValue = string | string[] | undefined | null; -export type SceneObjectUrlValues = Record; diff --git a/public/app/features/scenes/core/utils.ts b/public/app/features/scenes/core/utils.ts deleted file mode 100644 index c2474615bf7..00000000000 --- a/public/app/features/scenes/core/utils.ts +++ /dev/null @@ -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, TState extends SceneObjectState>( - sceneObject: SceneObjectBase, - withState?: Partial -): 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); -} diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx index 04f65e48aa7..dc88bf95a05 100644 --- a/public/app/features/scenes/dashboard/DashboardScene.tsx +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -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; diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.test.ts b/public/app/features/scenes/dashboard/DashboardsLoader.test.ts index f9ea8d38902..63e672ace35 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.test.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.test.ts @@ -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', () => { diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index 4e757f02daf..d2bcd4de2a7 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -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 { diff --git a/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx b/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx index eaa6a77d274..9196f200195 100644 --- a/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx +++ b/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx @@ -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, diff --git a/public/app/features/scenes/editor/SceneEditManager.tsx b/public/app/features/scenes/editor/SceneEditManager.tsx index ba40ce6975a..7fbddae3f70 100644 --- a/public/app/features/scenes/editor/SceneEditManager.tsx +++ b/public/app/features/scenes/editor/SceneEditManager.tsx @@ -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'; diff --git a/public/app/features/scenes/editor/SceneObjectEditor.tsx b/public/app/features/scenes/editor/SceneObjectEditor.tsx index 269303bd9a7..1159c9b03d6 100644 --- a/public/app/features/scenes/editor/SceneObjectEditor.tsx +++ b/public/app/features/scenes/editor/SceneObjectEditor.tsx @@ -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; } diff --git a/public/app/features/scenes/editor/SceneObjectTree.tsx b/public/app/features/scenes/editor/SceneObjectTree.tsx index bd612a6e172..44ab3e9ad3d 100644 --- a/public/app/features/scenes/editor/SceneObjectTree.tsx +++ b/public/app/features/scenes/editor/SceneObjectTree.tsx @@ -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; diff --git a/public/app/features/scenes/querying/SceneQueryRunner.test.ts b/public/app/features/scenes/querying/SceneQueryRunner.test.ts deleted file mode 100644 index 465695d4750..00000000000 --- a/public/app/features/scenes/querying/SceneQueryRunner.test.ts +++ /dev/null @@ -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({ - 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]); - }); - }); -}); diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts deleted file mode 100644 index fb17661252f..00000000000 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ /dev/null @@ -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 { - 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 { - 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 = (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 }))); - }) - ); -}; diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx index 72ac5c7d073..a8c3c861987 100644 --- a/public/app/features/scenes/scenes/demo.tsx +++ b/public/app/features/scenes/scenes/demo.tsx @@ -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', diff --git a/public/app/features/scenes/scenes/grid.tsx b/public/app/features/scenes/scenes/grid.tsx index 0a8ca594e86..6cbfce18580 100644 --- a/public/app/features/scenes/scenes/grid.tsx +++ b/public/app/features/scenes/scenes/grid.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx index df830020023..dc856427bb2 100644 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -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', diff --git a/public/app/features/scenes/scenes/gridMultiple.tsx b/public/app/features/scenes/scenes/gridMultiple.tsx index 6a2efeee7ae..bfd49bc6e00 100644 --- a/public/app/features/scenes/scenes/gridMultiple.tsx +++ b/public/app/features/scenes/scenes/gridMultiple.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx index ec5db398dca..683d118932b 100644 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/gridWithRow.tsx b/public/app/features/scenes/scenes/gridWithRow.tsx index dd95ef0a916..d3739040722 100644 --- a/public/app/features/scenes/scenes/gridWithRow.tsx +++ b/public/app/features/scenes/scenes/gridWithRow.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/gridWithRows.tsx b/public/app/features/scenes/scenes/gridWithRows.tsx index f37958ad5b3..c3bde62bb1f 100644 --- a/public/app/features/scenes/scenes/gridWithRows.tsx +++ b/public/app/features/scenes/scenes/gridWithRows.tsx @@ -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'; diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx index 40bc9eed550..bdb94daebe4 100644 --- a/public/app/features/scenes/scenes/index.tsx +++ b/public/app/features/scenes/scenes/index.tsx @@ -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 = {}; +const cache: Record }> = {}; export function getSceneByTitle(title: string, standalone = true) { if (cache[title]) { diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx index f7ded62758b..9243a1c1abc 100644 --- a/public/app/features/scenes/scenes/nested.tsx +++ b/public/app/features/scenes/scenes/nested.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/queries.ts b/public/app/features/scenes/scenes/queries.ts index 2aab365b2ad..cce0057e0a7 100644 --- a/public/app/features/scenes/scenes/queries.ts +++ b/public/app/features/scenes/scenes/queries.ts @@ -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, queryRunnerOverrides?: Partial diff --git a/public/app/features/scenes/scenes/queryVariableDemo.tsx b/public/app/features/scenes/scenes/queryVariableDemo.tsx index a3d0ef6eff2..4eb211d8d59 100644 --- a/public/app/features/scenes/scenes/queryVariableDemo.tsx +++ b/public/app/features/scenes/scenes/queryVariableDemo.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/sceneWithRows.tsx b/public/app/features/scenes/scenes/sceneWithRows.tsx index f34b47b6015..c7d5d7f4f1f 100644 --- a/public/app/features/scenes/scenes/sceneWithRows.tsx +++ b/public/app/features/scenes/scenes/sceneWithRows.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/transformations.tsx b/public/app/features/scenes/scenes/transformations.tsx index 16725a79309..739f88c820f 100644 --- a/public/app/features/scenes/scenes/transformations.tsx +++ b/public/app/features/scenes/scenes/transformations.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx index efa1be9cb36..14547e4302d 100644 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -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({ diff --git a/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts b/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts deleted file mode 100644 index 12859e8ff20..00000000000 --- a/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - SceneObjectState, - SceneObjectUrlSyncHandler, - SceneObjectWithUrlSync, - SceneObjectUrlValues, -} from '../core/types'; - -interface SceneObjectUrlSyncConfigOptions { - keys: string[]; -} - -export class SceneObjectUrlSyncConfig implements SceneObjectUrlSyncHandler { - private _keys: string[]; - - public constructor(private _sceneObject: SceneObjectWithUrlSync, _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); - } -} diff --git a/public/app/features/scenes/services/UrlSyncManager.test.ts b/public/app/features/scenes/services/UrlSyncManager.test.ts deleted file mode 100644 index 22c488ab086..00000000000 --- a/public/app/features/scenes/services/UrlSyncManager.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/public/app/features/scenes/services/UrlSyncManager.ts b/public/app/features/scenes/services/UrlSyncManager.ts deleted file mode 100644 index dce5b4bfc0c..00000000000 --- a/public/app/features/scenes/services/UrlSyncManager.ts +++ /dev/null @@ -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 = 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(); - - 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); -} diff --git a/public/app/features/scenes/variables/VariableDependencyConfig.test.ts b/public/app/features/scenes/variables/VariableDependencyConfig.test.ts deleted file mode 100644 index 3e14a66dd09..00000000000 --- a/public/app/features/scenes/variables/VariableDependencyConfig.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/public/app/features/scenes/variables/VariableDependencyConfig.ts b/public/app/features/scenes/variables/VariableDependencyConfig.ts deleted file mode 100644 index c07d3e38327..00000000000 --- a/public/app/features/scenes/variables/VariableDependencyConfig.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { variableRegex } from 'app/features/variables/utils'; - -import { SceneObject, SceneObjectState } from '../core/types'; - -import { SceneVariable, SceneVariableDependencyConfigLike } from './types'; - -interface VariableDependencyConfigOptions { - /** - * State paths to scan / extract variable dependencies from. Leave empty to scan all paths. - */ - statePaths?: Array; - /** - * 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 implements SceneVariableDependencyConfigLike { - private _state: TState | undefined; - private _dependencies = new Set(); - private _statePaths?: Array; - private _onReferencedVariableValueChanged: () => void; - - public scanCount = 0; - - public constructor(private _sceneObject: SceneObject, options: VariableDependencyConfigOptions) { - 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) { - 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 { - 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 ''; -}; diff --git a/public/app/features/scenes/variables/components/VariableValueSelect.tsx b/public/app/features/scenes/variables/components/VariableValueSelect.tsx deleted file mode 100644 index 348821f2174..00000000000 --- a/public/app/features/scenes/variables/components/VariableValueSelect.tsx +++ /dev/null @@ -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) { - const { value, key, loading } = model.useState(); - - return ( -