Dashboard-Scene: View panel as table in edit mode (#83077)

* WIP: working functionality

* betterer

* Fully working: Alerts show up, toggling table view doesn't update viz type in options pane

* betterer

* improve

* betterer

* Refactoring a bit

* wrong step

* move data provider to vizPanel

* Update

* update

* More refactorings

* Fix InspectJsonTab tests (except 1); remove obsolete PanelControls

* Fixed test

* Update

* minor fix

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Haris Rozajac 2024-02-22 10:34:21 -07:00 committed by GitHub
parent a564c8c439
commit 28fa2849df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 251 additions and 99 deletions

View File

@ -1,12 +1,21 @@
import { FieldType, getDefaultTimeRange, LoadingState, standardTransformersRegistry, toDataFrame } from '@grafana/data';
import { of } from 'rxjs';
import {
FieldType,
getDefaultTimeRange,
LoadingState,
PanelData,
standardTransformersRegistry,
toDataFrame,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import {
SceneCanvasText,
SceneDataNode,
SceneDataTransformer,
SceneGridItem,
SceneGridLayout,
SceneQueryRunner,
VizPanel,
} from '@grafana/scenes';
import * as libpanels from 'app/features/library-panels/state/api';
@ -34,8 +43,45 @@ jest.mock('@grafana/runtime', () => ({
getPluginLinkExtensions: jest.fn(() => ({
extensions: [],
})),
getDataSourceSrv: () => {
return {
get: jest.fn().mockResolvedValue({
getRef: () => ({ uid: 'ds1' }),
}),
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
};
},
}));
const runRequestMock = jest.fn().mockReturnValue(
of<PanelData>({
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
series: [
toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }],
}),
],
request: {
app: 'dashboard',
requestId: 'request-id',
dashboardUID: 'asd',
interval: '1s',
panelId: 1,
range: getDefaultTimeRange(),
targets: [],
timezone: 'utc',
intervalMs: 1000,
startTime: 1,
scopedVars: {
__sceneObject: { value: new SceneCanvasText({ text: 'asd' }) },
},
},
})
);
setRunRequest(runRequestMock);
describe('InspectJsonTab', () => {
it('Can show panel json', async () => {
const { tab } = await buildTestScene();
@ -121,31 +167,9 @@ function buildTestPanel() {
},
},
],
$data: new SceneDataNode({
data: {
state: LoadingState.Done,
series: [
toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }],
}),
],
timeRange: getDefaultTimeRange(),
request: {
app: 'dashboard',
requestId: 'request-id',
dashboardUID: 'asd',
interval: '1s',
panelId: 1,
range: getDefaultTimeRange(),
targets: [],
timezone: 'utc',
intervalMs: 1000,
startTime: 1,
scopedVars: {
__sceneObject: { value: new SceneCanvasText({ text: 'asd' }) },
},
},
},
$data: new SceneQueryRunner({
datasource: { uid: 'abcdef' },
queries: [{ refId: 'A' }],
}),
}),
});

View File

@ -26,6 +26,7 @@ import { InspectTab } from 'app/features/inspector/types';
import { getPrettyJSON } from 'app/features/inspector/utils/utils';
import { reportPanelInspectInteraction } from 'app/features/search/page/reporting';
import { VizPanelManager } from '../panel-edit/VizPanelManager';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene';
@ -60,7 +61,7 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
public getOptions(): Array<SelectableValue<ShowContent>> {
const panel = this.state.panelRef.resolve();
const dataProvider = panel.state.$data;
const dataProvider = panel.state.$data ?? panel.parent?.state.$data;
const options: Array<SelectableValue<ShowContent>> = [
{
@ -201,10 +202,14 @@ function getJsonText(show: ShowContent, panel: VizPanel): string {
case 'panel-json': {
reportPanelInspectInteraction(InspectTab.JSON, 'panelData');
if (panel.parent instanceof SceneGridItem || panel.parent instanceof PanelRepeaterGridItem) {
objToStringify = gridItemToPanel(panel.parent);
} else if (panel.parent instanceof LibraryVizPanel) {
const parent = panel.parent!;
if (parent instanceof SceneGridItem || parent instanceof PanelRepeaterGridItem) {
objToStringify = gridItemToPanel(parent);
} else if (parent instanceof LibraryVizPanel) {
objToStringify = libraryPanelChildToLegacyRepresentation(panel);
} else if (parent instanceof VizPanelManager) {
objToStringify = parent.getPanelSaveModel();
}
break;
}
@ -246,18 +251,22 @@ function libraryPanelChildToLegacyRepresentation(panel: VizPanel<{}, {}>) {
if (!(panel.parent instanceof LibraryVizPanel)) {
throw 'Panel not child of LibraryVizPanel';
}
if (!(panel.parent.parent instanceof SceneGridItem)) {
throw 'LibraryPanel not child of SceneGridItem';
}
const gridItem = panel.parent.parent;
const libraryPanelObj = gridItemToPanel(gridItem);
const panelObj = vizPanelToPanel(panel);
panelObj.gridPos = {
x: gridItem.state.x || 0,
y: gridItem.state.y || 0,
h: gridItem.state.height || 0,
w: gridItem.state.width || 0,
};
return { ...libraryPanelObj, ...panelObj };
}

View File

@ -349,8 +349,8 @@ async function clickNewButton() {
function createModel(dashboard: DashboardModel) {
const scene = createDashboardSceneFromDashboardModel(dashboard);
const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34));
const model = new PanelDataAlertingTab(new VizPanelManager(vizPanel!.clone()));
const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34))!;
const model = new PanelDataAlertingTab(VizPanelManager.createFor(vizPanel));
jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene);
return model;
}

View File

@ -174,7 +174,7 @@ const setupVizPanelManger = (panelId: string) => {
const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(scene, panelId)!;
const vizPanelManager = new VizPanelManager(panel.clone());
const vizPanelManager = VizPanelManager.createFor(panel);
// The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it
// @ts-expect-error

View File

@ -0,0 +1,33 @@
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { InlineSwitch } from '@grafana/ui';
import { PanelEditor } from './PanelEditor';
export interface Props {
panelEditor: PanelEditor;
}
export function PanelEditControls({ panelEditor }: Props) {
const vizManager = panelEditor.state.vizManager;
const { panel, tableView } = vizManager.useState();
const skipDataQuery = config.panels[panel.state.pluginId].skipDataQuery;
return (
<>
{!skipDataQuery && (
<InlineSwitch
label="Table view"
showLabel={true}
id="table-view"
value={tableView ? true : false}
onClick={() => vizManager.toggleTableView()}
aria-label="toggle-table-view"
data-testid={selectors.components.PanelEditor.toggleTableView}
/>
)}
</>
);
}

View File

@ -2,14 +2,9 @@ import * as H from 'history';
import { NavIndex } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { SceneGridItem, SceneObject, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import {
findVizPanelByKey,
getDashboardSceneFor,
getPanelIdForVizPanel,
getVizPanelKeyForPanelId,
} from '../utils/utils';
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils';
import { PanelDataPane } from './PanelDataPane/PanelDataPane';
import { PanelEditorRenderer } from './PanelEditorRenderer';
@ -17,7 +12,6 @@ import { PanelOptionsPane } from './PanelOptionsPane';
import { VizPanelManager } from './VizPanelManager';
export interface PanelEditorState extends SceneObjectState {
controls?: SceneObject[];
isDirty?: boolean;
panelId: number;
optionsPane: PanelOptionsPane;
@ -32,7 +26,6 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
public constructor(state: PanelEditorState) {
super(state);
this.addActivationHandler(this._activationHandler.bind(this));
}
@ -90,25 +83,19 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
public commitChanges() {
const dashboard = getDashboardSceneFor(this);
const sourcePanel = findVizPanelByKey(dashboard.state.body, getVizPanelKeyForPanelId(this.state.panelId));
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
if (sourcePanel!.parent instanceof SceneGridItem) {
sourcePanel!.parent.setState({ body: this.state.vizManager.state.panel.clone() });
}
this.state.vizManager.commitChanges();
}
}
export function buildPanelEditScene(panel: VizPanel): PanelEditor {
const panelClone = panel.clone();
const vizPanelMgr = new VizPanelManager(panelClone);
return new PanelEditor({
panelId: getPanelIdForVizPanel(panel),
optionsPane: new PanelOptionsPane({}),
vizManager: vizPanelMgr,
vizManager: VizPanelManager.createFor(panel),
});
}

View File

@ -127,13 +127,6 @@ function getStyles(theme: GrafanaTheme2) {
minHeight: 0,
gap: '8px',
}),
controls: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(2, 0, 2, 2),
}),
optionsPane: css({
flexDirection: 'column',
borderLeft: `1px solid ${theme.colors.border.weak}`,

View File

@ -0,0 +1,35 @@
import { SceneDataProvider, SceneDataState, SceneObjectBase } from '@grafana/scenes';
export class ShareDataProvider extends SceneObjectBase<SceneDataState> implements SceneDataProvider {
public constructor(private _source: SceneDataProvider) {
super(_source.state);
this.addActivationHandler(() => this.activationHandler());
}
private activationHandler() {
this._subs.add(this._source.subscribeToState((state) => this.setState({ data: state.data })));
this.setState(this._source.state);
}
public setContainerWidth(width: number) {
if (this.state.$data && this.state.$data.setContainerWidth) {
this.state.$data.setContainerWidth(width);
}
}
public isDataReadyToDisplay() {
if (!this._source.isDataReadyToDisplay) {
return true;
}
return this._source.isDataReadyToDisplay?.();
}
public cancelQuery() {
this._source.cancelQuery?.();
}
public getResultsStream() {
return this._source.getResultsStream!();
}
}

View File

@ -2,7 +2,7 @@ import { map, of } from 'rxjs';
import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { InspectTab } from 'app/features/inspector/types';
@ -140,7 +140,7 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('VizPanelManager', () => {
describe('changePluginType', () => {
describe('When changing plugin', () => {
it('Should successfully change from one viz type to another', () => {
const { vizPanelManager } = setupTest('panel-1');
expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries');
@ -169,7 +169,7 @@ describe('VizPanelManager', () => {
},
});
const vizPanelManager = new VizPanelManager(vizPanel);
const vizPanelManager = VizPanelManager.createFor(vizPanel);
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toBe('Custom');
expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toBe(overrides);
@ -193,7 +193,7 @@ describe('VizPanelManager', () => {
fieldConfig: { defaults: { custom: 'Custom' }, overrides: [] },
});
const vizPanelManager = new VizPanelManager(vizPanel);
const vizPanelManager = VizPanelManager.createFor(vizPanel);
vizPanelManager.changePluginType('timeseries');
//@ts-ignore
@ -599,7 +599,6 @@ describe('VizPanelManager', () => {
},
]);
expect(vizPanelManager.panelData).toBeInstanceOf(SceneDataTransformer);
expect(vizPanelManager.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id);
// Changing dashboard query to a panel with queries only
@ -613,7 +612,6 @@ describe('VizPanelManager', () => {
},
]);
expect(vizPanelManager.panelData).toBeInstanceOf(SceneDataTransformer);
expect(vizPanelManager.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id);
});
});
@ -624,8 +622,7 @@ const setupTest = (panelId: string) => {
const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} });
const panel = findVizPanelByKey(scene, panelId)!;
const vizPanelManager = new VizPanelManager(panel.clone());
const vizPanelManager = VizPanelManager.createFor(panel);
// The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it
// @ts-expect-error
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene));

View File

@ -21,10 +21,12 @@ import {
DeepPartial,
SceneQueryRunner,
sceneGraph,
SceneDataProvider,
SceneDataTransformer,
PanelBuilders,
SceneGridItem,
SceneObjectRef,
} from '@grafana/scenes';
import { DataQuery, DataTransformerConfig } from '@grafana/schema';
import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
@ -34,12 +36,21 @@ import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
interface VizPanelManagerState extends SceneObjectState {
panel: VizPanel;
sourcePanel: SceneObjectRef<VizPanel>;
datasource?: DataSourceApi;
dsSettings?: DataSourceInstanceSettings;
tableView?: VizPanel;
}
export enum DisplayMode {
Fill = 0,
Fit = 1,
Exact = 2,
}
// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation.
@ -49,18 +60,29 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
{ options: DeepPartial<{}>; fieldConfig: FieldConfigSource<DeepPartial<{}>> } | undefined
> = {};
public constructor(panel: VizPanel) {
super({ panel });
public constructor(state: VizPanelManagerState) {
super(state);
this.addActivationHandler(() => this._onActivate());
}
/**
* Will clone the source panel and move the data provider to
* live on the VizPanelManager level instead of the VizPanel level
*/
public static createFor(sourcePanel: VizPanel) {
return new VizPanelManager({
panel: sourcePanel.clone({ $data: undefined }),
$data: sourcePanel.state.$data?.clone(),
sourcePanel: sourcePanel.getRef(),
});
}
private _onActivate() {
this.loadDataSource();
}
private async loadDataSource() {
const dataObj = this.state.panel.state.$data;
const dataObj = this.state.$data;
if (!dataObj) {
return;
@ -96,7 +118,7 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
}
}
public changePluginType(pluginType: string) {
public changePluginType(pluginId: string) {
const {
options: prevOptions,
fieldConfig: prevFieldConfig,
@ -105,16 +127,19 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
} = sceneUtils.cloneSceneObjectState(this.state.panel.state);
// clear custom options
let newFieldConfig = { ...prevFieldConfig };
newFieldConfig.defaults = {
...newFieldConfig.defaults,
custom: {},
let newFieldConfig: FieldConfigSource = {
defaults: {
...prevFieldConfig.defaults,
custom: {},
},
overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp),
};
newFieldConfig.overrides = filterFieldConfigOverrides(newFieldConfig.overrides, isStandardFieldProp);
this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig };
const cachedOptions = this._cachedPluginOptions[pluginType]?.options;
const cachedFieldConfig = this._cachedPluginOptions[pluginType]?.fieldConfig;
const cachedOptions = this._cachedPluginOptions[pluginId]?.options;
const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig;
if (cachedFieldConfig) {
newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig);
}
@ -122,19 +147,19 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
const newPanel = new VizPanel({
options: cachedOptions ?? {},
fieldConfig: newFieldConfig,
pluginId: pluginType,
pluginId: pluginId,
...restOfOldState,
});
// When changing from non-data to data panel, we need to add a new data provider
if (!restOfOldState.$data && !config.panels[pluginType].skipDataQuery) {
if (!this.state.$data && !config.panels[pluginId].skipDataQuery) {
let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid;
if (!ds) {
ds = config.defaultDatasource;
}
newPanel.setState({
this.setState({
$data: new SceneDataTransformer({
$data: new SceneQueryRunner({
datasource: {
@ -153,8 +178,9 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
options: newPanel.state.options,
fieldConfig: newPanel.state.fieldConfig,
id: 1,
type: pluginType,
type: pluginId,
};
const newOptions = newPlugin?.onPanelTypeChanged?.(panel, prevPluginId, prevOptions, prevFieldConfig);
if (newOptions) {
newPanel.onOptionsChange(newOptions, true);
@ -208,14 +234,17 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
if (options.maxDataPoints !== dataObj.state.maxDataPoints) {
dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined;
}
if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) {
dataObjStateUpdate.minInterval = options.minInterval;
}
if (options.timeRange) {
timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined;
timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined;
timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide;
}
if (timeRangeObj instanceof PanelTimeRange) {
if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) {
// update time override
@ -264,35 +293,76 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
get queryRunner(): SceneQueryRunner {
// Panel data object is always SceneQueryRunner wrapped in a SceneDataTransformer
const runner = getQueryRunnerFor(this.state.panel);
const runner = getQueryRunnerFor(this);
if (!runner) {
throw new Error('Query runner not found');
}
return runner;
}
get dataTransformer(): SceneDataTransformer {
const provider = this.state.panel.state.$data;
const provider = this.state.$data;
if (!provider || !(provider instanceof SceneDataTransformer)) {
throw new Error('Could not find SceneDataTransformer for panel');
}
return provider;
}
get panelData(): SceneDataProvider {
return this.state.panel.state.$data!;
public toggleTableView() {
if (this.state.tableView) {
this.setState({ tableView: undefined });
return;
}
this.setState({
tableView: PanelBuilders.table()
.setTitle('')
.setOption('showTypeIcons', true)
.setOption('showHeader', true)
.build(),
});
}
public commitChanges() {
const sourcePanel = this.state.sourcePanel.resolve();
if (sourcePanel.parent instanceof SceneGridItem) {
sourcePanel.parent.setState({
body: this.state.panel.clone({
$data: this.state.$data?.clone(),
}),
});
}
}
/**
* Used from inspect json tab to view the current persisted model
*/
public getPanelSaveModel(): Panel | object {
const sourcePanel = this.state.sourcePanel.resolve();
if (sourcePanel.parent instanceof SceneGridItem) {
const parentClone = sourcePanel.parent.clone({
body: this.state.panel.clone({
$data: this.state.$data?.clone(),
}),
});
return gridItemToPanel(parentClone);
}
return { error: 'Unsupported panel parent' };
}
public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => {
const { panel } = model.useState();
const { panel, tableView } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<panel.Component model={panel} />
</div>
);
const panelToShow = tableView ?? panel;
return <div className={styles.wrapper}>{<panelToShow.Component model={panelToShow} />}</div>;
};
}

View File

@ -13,6 +13,7 @@ import {
} from '@grafana/scenes';
import { Box, Stack, useStyles2 } from '@grafana/ui';
import { PanelEditControls } from '../panel-edit/PanelEditControls';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardLinksControls } from './DashboardLinksControls';
@ -51,6 +52,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
))}
<Box grow={1} />
{!editPanel && <DashboardLinksControls links={links} uid={dashboard.state.uid} />}
{editPanel && <PanelEditControls panelEditor={editPanel} />}
</Stack>
{!hideTimeControls && (
<Stack justifyContent={'flex-end'}>

View File

@ -155,12 +155,14 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu
return undefined;
}
if (sceneObject.state.$data instanceof SceneQueryRunner) {
return sceneObject.state.$data;
const dataProvider = sceneObject.state.$data ?? sceneObject.parent?.state.$data;
if (dataProvider instanceof SceneQueryRunner) {
return dataProvider;
}
if (sceneObject.state.$data instanceof SceneDataTransformer) {
return getQueryRunnerFor(sceneObject.state.$data);
if (dataProvider instanceof SceneDataTransformer) {
return getQueryRunnerFor(dataProvider);
}
return undefined;