DashboardScene: Repeat panel by variable (#74294)

* Progress

* think this a bad approach

* Scene panel repeats looking good

* Update

* update

* Update

* Use key instead for inspect/view

* refactorings to improve tests

* Update

* More tests

* Update

* added support for key / value variables

* Update

* Fixes

* remove log

* Update

* Removed old gdev templating dashboard and added new and improved one

* Update

* Added repeating panels coded demo

* Update to latest scenes lib

* review feedback fixes

* update

* Sync schema
This commit is contained in:
Torkel Ödegaard 2023-09-05 13:51:46 +02:00 committed by GitHub
parent f18cd13f2b
commit d82a3c9fc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1184 additions and 1498 deletions

View File

@ -106,7 +106,7 @@
}
],
"schemaVersion": 36,
"tags": [],
"tags": ["gdev", "templating"],
"templating": {
"list": [
{
@ -199,7 +199,7 @@
},
"timepicker": {},
"timezone": "utc",
"title": "Repeating a panel horizontally",
"title": "Templating - Repeating a panel horizontally",
"uid": "WVpf2jp7z",
"version": 1,
"weekStart": ""

View File

@ -102,7 +102,7 @@
}
],
"schemaVersion": 34,
"tags": [],
"tags": ["gdev", "templating"],
"templating": {
"list": [
{

View File

@ -0,0 +1,423 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"gridPos": {
"h": 2,
"w": 24,
"x": 0,
"y": 0
},
"id": 15,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "<div class=\"center-vh\">\n Horizontally repeated panel below\n</div>",
"mode": "markdown"
},
"pluginVersion": "10.2.0-pre",
"type": "text"
},
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisShow": false,
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 8,
"x": 0,
"y": 2
},
"id": 2,
"maxPerRow": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"repeat": "server",
"repeatDirection": "h",
"targets": [
{
"alias": "server = $server",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "server=$server",
"type": "timeseries"
},
{
"gridPos": {
"h": 20,
"w": 16,
"x": 0,
"y": 12
},
"id": 10,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "### \n\nIt also has a variable with different value and text representations (A=1, B=2, etc). \nTo test that this works for the scoped variable. \n\nIn the title the text representation should be seen (A,B,C, etc). In the legend you\nshould see both the text and value (id). \n\n",
"mode": "markdown"
},
"pluginVersion": "10.2.0-pre",
"title": "Panel to the right is configured for vertical repeat",
"type": "text"
},
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "blue",
"mode": "fixed"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisShow": false,
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "hue",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 8,
"x": 16,
"y": 12
},
"id": 5,
"maxDataPoints": 50,
"maxPerRow": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"repeat": "host",
"repeatDirection": "v",
"targets": [
{
"alias": "host = ${host:text} / id = $host",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "host_name = $host",
"type": "timeseries"
}
],
"refresh": "",
"schemaVersion": 38,
"tags": ["gdev", "templating"],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": [
"A",
"B",
"C"
],
"value": [
"A",
"B",
"C"
]
},
"hide": 0,
"includeAll": true,
"multi": true,
"name": "server",
"options": [
{
"selected": false,
"text": "All",
"value": "$__all"
},
{
"selected": true,
"text": "A",
"value": "A"
},
{
"selected": true,
"text": "B",
"value": "B"
},
{
"selected": true,
"text": "C",
"value": "C"
},
{
"selected": false,
"text": "D",
"value": "D"
},
{
"selected": false,
"text": "E",
"value": "E"
},
{
"selected": false,
"text": "F",
"value": "F"
},
{
"selected": false,
"text": "E",
"value": "E"
},
{
"selected": false,
"text": "G",
"value": "G"
},
{
"selected": false,
"text": "H",
"value": "H"
},
{
"selected": false,
"text": "I",
"value": "I"
},
{
"selected": false,
"text": "J",
"value": "J"
},
{
"selected": false,
"text": "K",
"value": "K"
},
{
"selected": false,
"text": "L",
"value": "L"
}
],
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
"queryValue": "",
"skipUrlSync": false,
"type": "custom"
},
{
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"hide": 0,
"includeAll": true,
"multi": true,
"name": "host",
"options": [
{
"selected": true,
"text": "All",
"value": "$__all"
},
{
"selected": false,
"text": "A",
"value": "1"
},
{
"selected": false,
"text": "B",
"value": "2"
},
{
"selected": false,
"text": "C",
"value": "3"
},
{
"selected": false,
"text": "D",
"value": "4"
},
{
"selected": false,
"text": "E",
"value": "5"
}
],
"query": "A : 1, B : 2,C : 3, D : 4, E : 5",
"queryValue": "",
"skipUrlSync": false,
"type": "custom"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Templating - Repeating Panels",
"uid": "templating-repeating-panels",
"version": 37,
"weekStart": ""
}

View File

@ -618,6 +618,13 @@ local dashboard = grafana.dashboard;
id: 0,
}
},
dashboard.new('templating-repeating-panels', import '../dev-dashboards/feature-templating/templating-repeating-panels.json') +
resource.addMetadata('folder', 'dev-dashboards') +
{
spec+: {
id: 0,
}
},
dashboard.new('templating-textbox-e2e-scenarios', import '../dev-dashboards/feature-templating/templating-textbox-e2e-scenarios.json') +
resource.addMetadata('folder', 'dev-dashboards') +
{
@ -646,13 +653,6 @@ local dashboard = grafana.dashboard;
id: 0,
}
},
dashboard.new('testdata-repeating', import '../dev-dashboards/feature-templating/testdata-repeating.json') +
resource.addMetadata('folder', 'dev-dashboards') +
{
spec+: {
id: 0,
}
},
dashboard.new('testdata-test-variable-output', import '../dev-dashboards/feature-templating/testdata-test-variable-output.json') +
resource.addMetadata('folder', 'dev-dashboards') +
{

View File

@ -463,9 +463,9 @@ Dashboard panels are the basic visualization building blocks.
| `libraryPanel` | [LibraryPanelRef](#librarypanelref) | No | | A library panel is a reusable panel that you can use in any dashboard.<br/>When you make a change to a library panel, that change propagates to all instances of where the panel is used.<br/>Library panels streamline reuse of panels across multiple dashboards. |
| `links` | [DashboardLink](#dashboardlink)[] | No | | Panel links. |
| `maxDataPoints` | number | No | | The maximum number of data points that the panel queries are retrieving. |
| `maxPerRow` | number | No | | Option for repeated panels that controls max items per row<br/>Only relevant for horizontally repeated panels |
| `pluginVersion` | string | No | | The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. |
| `repeatDirection` | string | No | `h` | Direction to repeat in if 'repeat' is set.<br/>`h` for horizontal, `v` for vertical.<br/>Possible values are: `h`, `v`. |
| `repeatPanelId` | integer | No | | Id of the repeating panel. |
| `repeat` | string | No | | Name of template variable to repeat for. |
| `tags` | string[] | No | | Tags for the panel. |
| `targets` | [Target](#target)[] | No | | Depends on the panel plugin. See the plugin documentation for details. |
@ -611,9 +611,9 @@ Dashboard panels are the basic visualization building blocks.
| `libraryPanel` | [LibraryPanelRef](#librarypanelref) | No | | A library panel is a reusable panel that you can use in any dashboard.<br/>When you make a change to a library panel, that change propagates to all instances of where the panel is used.<br/>Library panels streamline reuse of panels across multiple dashboards. |
| `links` | [DashboardLink](#dashboardlink)[] | No | | Panel links. |
| `maxDataPoints` | number | No | | The maximum number of data points that the panel queries are retrieving. |
| `maxPerRow` | number | No | | Option for repeated panels that controls max items per row<br/>Only relevant for horizontally repeated panels |
| `pluginVersion` | string | No | | The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. |
| `repeatDirection` | string | No | `h` | Direction to repeat in if 'repeat' is set.<br/>`h` for horizontal, `v` for vertical.<br/>Possible values are: `h`, `v`. |
| `repeatPanelId` | integer | No | | Id of the repeating panel. |
| `repeat` | string | No | | Name of template variable to repeat for. |
| `tags` | string[] | No | | Tags for the panel. |
| `targets` | [Target](#target)[] | No | | Depends on the panel plugin. See the plugin documentation for details. |

View File

@ -539,8 +539,9 @@ lineage: schemas: [{
// `h` for horizontal, `v` for vertical.
repeatDirection?: *"h" | "v"
// Id of the repeating panel.
repeatPanelId?: int64
// Option for repeated panels that controls max items per row
// Only relevant for horizontally repeated panels
maxPerRow?: number
// The maximum number of data points that the panel queries are retrieving.
maxDataPoints?: number

View File

@ -245,7 +245,7 @@
"@grafana/lezer-traceql": "0.0.4",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^0.27.0",
"@grafana/scenes": "^0.29.0",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "^7.4.0",

View File

@ -693,6 +693,11 @@ export interface Panel {
* The maximum number of data points that the panel queries are retrieving.
*/
maxDataPoints?: number;
/**
* Option for repeated panels that controls max items per row
* Only relevant for horizontally repeated panels
*/
maxPerRow?: number;
/**
* It depends on the panel plugin. They are specified by the Options field in panel plugin schemas.
*/
@ -710,10 +715,6 @@ export interface Panel {
* `h` for horizontal, `v` for vertical.
*/
repeatDirection?: ('h' | 'v');
/**
* Id of the repeating panel.
*/
repeatPanelId?: number;
/**
* Tags for the panel.
*/

View File

@ -546,6 +546,10 @@ type Panel struct {
// The maximum number of data points that the panel queries are retrieving.
MaxDataPoints *float32 `json:"maxDataPoints,omitempty"`
// Option for repeated panels that controls max items per row
// Only relevant for horizontally repeated panels
MaxPerRow *float32 `json:"maxPerRow,omitempty"`
// It depends on the panel plugin. They are specified by the Options field in panel plugin schemas.
Options map[string]any `json:"options"`
@ -559,9 +563,6 @@ type Panel struct {
// `h` for horizontal, `v` for vertical.
RepeatDirection *PanelRepeatDirection `json:"repeatDirection,omitempty"`
// Id of the repeating panel.
RepeatPanelId *int64 `json:"repeatPanelId,omitempty"`
// Tags for the panel.
Tags []string `json:"tags,omitempty"`

View File

@ -7,19 +7,19 @@ describe('DashboardScene', () => {
it('Should set inspectPanelKey when url has inspect key', () => {
const scene = buildTestScene();
scene.urlSync?.updateFromUrl({ inspect: '2' });
expect(scene.state.inspectPanelId).toBe('2');
expect(scene.state.inspectPanelKey).toBe('2');
});
it('Should handle inspect key that is not found', () => {
const scene = buildTestScene();
scene.urlSync?.updateFromUrl({ inspect: '12321' });
expect(scene.state.inspectPanelId).toBe(undefined);
expect(scene.state.inspectPanelKey).toBe(undefined);
});
it('Should set viewPanelKey when url has viewPanel', () => {
const scene = buildTestScene();
scene.urlSync?.updateFromUrl({ viewPanel: '2' });
expect(scene.state.viewPanelId).toBe('2');
expect(scene.state.viewPanelKey).toBe('2');
});
});

View File

@ -16,7 +16,7 @@ import {
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer';
import { findVizPanelById, forceRenderChildren } from '../utils/utils';
import { findVizPanelByKey, forceRenderChildren } from '../utils/utils';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
@ -29,9 +29,9 @@ export interface DashboardSceneState extends SceneObjectState {
isEditing?: boolean;
isDirty?: boolean;
/** Panel to inspect */
inspectPanelId?: string;
inspectPanelKey?: string;
/** Panel to view in full screen */
viewPanelId?: string;
viewPanelKey?: string;
/** Scene object that handles the current drawer */
drawer?: SceneObject;
}
@ -128,7 +128,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
url: locationUtil.getUrlForPartial(location, { viewPanel: null, inspect: null }),
};
if (this.state.viewPanelId) {
if (this.state.viewPanelKey) {
pageNav = {
text: 'View panel',
parentItem: pageNav,
@ -141,8 +141,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
/**
* Returns the body (layout) or the full view panel
*/
public getBodyToRender(viewPanelId?: string): SceneObject {
const viewPanel = findVizPanelById(this, viewPanelId);
public getBodyToRender(viewPanelKey?: string): SceneObject {
const viewPanel = findVizPanelByKey(this, viewPanelKey);
return viewPanel ?? this.state.body;
}

View File

@ -3,7 +3,7 @@ import React from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { SceneComponentProps, SceneDebugger } from '@grafana/scenes';
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
@ -11,7 +11,7 @@ import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { controls, viewPanelId, drawer } = model.useState();
const { controls, viewPanelKey: viewPanelId, drawer } = model.useState();
const styles = useStyles2(getStyles);
const location = useLocation();
const pageNav = model.getPageNav(location);
@ -27,6 +27,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
{controls.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
<SceneDebugger scene={model} key={'scene-debugger'} />
</div>
)}
<div className={styles.body}>

View File

@ -4,7 +4,7 @@ import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes
import appEvents from 'app/core/app_events';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { findVizPanelById } from '../utils/utils';
import { findVizPanelByKey } from '../utils/utils';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
@ -17,41 +17,41 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
getUrlState(): SceneObjectUrlValues {
const state = this._scene.state;
return { inspect: state.inspectPanelId, viewPanel: state.viewPanelId };
return { inspect: state.inspectPanelKey, viewPanel: state.viewPanelKey };
}
updateFromUrl(values: SceneObjectUrlValues): void {
const { inspectPanelId, viewPanelId } = this._scene.state;
const { inspectPanelKey: inspectPanelId, viewPanelKey: viewPanelId } = this._scene.state;
const update: Partial<DashboardSceneState> = {};
// Handle inspect object state
if (typeof values.inspect === 'string') {
const panel = findVizPanelById(this._scene, values.inspect);
const panel = findVizPanelByKey(this._scene, values.inspect);
if (!panel) {
appEvents.emit(AppEvents.alertError, ['Panel not found']);
locationService.partial({ inspect: null });
return;
}
update.inspectPanelId = values.inspect;
update.inspectPanelKey = values.inspect;
update.drawer = new PanelInspectDrawer(panel);
} else if (inspectPanelId) {
update.inspectPanelId = undefined;
update.inspectPanelKey = undefined;
update.drawer = undefined;
}
// Handle view panel state
if (typeof values.viewPanel === 'string') {
const panel = findVizPanelById(this._scene, values.viewPanel);
const panel = findVizPanelByKey(this._scene, values.viewPanel);
if (!panel) {
appEvents.emit(AppEvents.alertError, ['Panel not found']);
locationService.partial({ viewPanel: null });
return;
}
update.viewPanelId = values.viewPanel;
update.viewPanelKey = values.viewPanel;
} else if (viewPanelId) {
update.viewPanelId = undefined;
update.viewPanelKey = undefined;
}
if (Object.keys(update).length > 0) {

View File

@ -13,7 +13,7 @@ interface Props {
}
export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
const { actions = [], isEditing, viewPanelId, isDirty, uid } = dashboard.useState();
const { actions = [], isEditing, viewPanelKey, isDirty, uid } = dashboard.useState();
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
if (uid) {
@ -29,7 +29,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
toolbarActions.push(<NavToolbarSeparator leftActionsSeparator key="separator" />);
if (viewPanelId) {
if (viewPanelKey) {
toolbarActions.push(
<Button
onClick={() => locationService.partial({ viewPanel: null })}

View File

@ -3,8 +3,6 @@ import { locationService } from '@grafana/runtime';
import { VizPanel, VizPanelMenu } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { getPanelIdForVizPanel } from '../utils/utils';
/**
* Behavior is called when VizPanelMenu is activated (ie when it's opened).
*/
@ -24,7 +22,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
iconClassName: 'eye',
shortcut: 'v',
// Hm... need the numeric id to be url compatible?
href: locationUtil.getUrlForPartial(location, { viewPanel: getPanelIdForVizPanel(panel) }),
href: locationUtil.getUrlForPartial(location, { viewPanel: panel.state.key }),
});
items.push({
@ -32,7 +30,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
iconClassName: 'info-circle',
shortcut: 'i',
// Hm... need the numeric id to be url compatible?
href: locationUtil.getUrlForPartial(location, { inspect: getPanelIdForVizPanel(panel) }),
href: locationUtil.getUrlForPartial(location, { inspect: panel.state.key }),
});
menu.setState({ items });

View File

@ -0,0 +1,143 @@
import { EmbeddedScene, SceneTimeRange, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { PanelRepeaterGridItem, RepeatDirection } from './PanelRepeaterGridItem';
describe('PanelRepeaterGridItem', () => {
it('Given scene with variable with 2 values', async () => {
const { scene, repeater } = buildScene({ variableQueryTime: 0 });
scene.activate();
repeater.activate();
expect(repeater.state.repeatedPanels?.length).toBe(5);
const panel1 = repeater.state.repeatedPanels![0];
const panel2 = repeater.state.repeatedPanels![1];
// Panels should have scoped variables
expect(panel1.state.$variables?.state.variables[0].getValue()).toBe('1');
expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A');
expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2');
});
it('Should wait for variable to load', async () => {
const { scene, repeater } = buildScene({ variableQueryTime: 1 });
scene.activate();
repeater.activate();
expect(repeater.state.repeatedPanels?.length).toBe(0);
await new Promise((r) => setTimeout(r, 10));
expect(repeater.state.repeatedPanels?.length).toBe(5);
});
it('Should adjust container height to fit panels direction is horizontal', async () => {
const { scene, repeater } = buildScene({ variableQueryTime: 0, maxPerRow: 2, itemHeight: 10 });
scene.activate();
repeater.activate();
// panels require 3 rows so total height should be 30
expect(repeater.state.height).toBe(30);
});
it('Should adjust container height to fit panels when direction is vertical', async () => {
const { scene, repeater } = buildScene({ variableQueryTime: 0, itemHeight: 10, repeatDirection: 'v' });
scene.activate();
repeater.activate();
// In vertical direction height itemCount * itemHeight
expect(repeater.state.height).toBe(50);
});
it('Should adjust itemHeight when container is resized, direction horizontal', async () => {
const { scene, repeater } = buildScene({
variableQueryTime: 0,
itemHeight: 10,
repeatDirection: 'h',
maxPerRow: 4,
});
scene.activate();
repeater.activate();
// Sould be two rows (5 panels and maxPerRow 5)
expect(repeater.state.height).toBe(20);
// resize container
repeater.setState({ height: 10 });
// given 2 current rows, the itemHeight is halved
expect(repeater.state.itemHeight).toBe(5);
});
it('Should adjust itemHeight when container is resized, direction vertical', async () => {
const { scene, repeater } = buildScene({
variableQueryTime: 0,
itemHeight: 10,
repeatDirection: 'v',
});
scene.activate();
repeater.activate();
// In vertical direction height itemCount * itemHeight
expect(repeater.state.height).toBe(50);
// resize container
repeater.setState({ height: 25 });
// given 5 rows with total height 25 gives new itemHeight of 5
expect(repeater.state.itemHeight).toBe(5);
});
});
interface SceneOptions {
variableQueryTime: number;
maxPerRow?: number;
itemHeight?: number;
repeatDirection?: RepeatDirection;
}
function buildScene(options: SceneOptions) {
const repeater = new PanelRepeaterGridItem({
variableName: 'server',
repeatedPanels: [],
repeatDirection: options.repeatDirection,
maxPerRow: options.maxPerRow,
itemHeight: options.itemHeight,
source: new VizPanel({
title: 'Panel $server',
pluginId: 'timeseries',
}),
});
const scene = new EmbeddedScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'server',
query: 'A.*',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
isMulti: true,
includeAll: true,
delayMs: options.variableQueryTime,
optionsToReturn: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
{ label: 'C', value: '3' },
{ label: 'D', value: '4' },
{ label: 'E', value: '5' },
],
}),
],
}),
body: repeater,
});
return { scene, repeater };
}

View File

@ -0,0 +1,244 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { config } from '@grafana/runtime';
import {
VizPanel,
SceneObjectBase,
VariableDependencyConfig,
SceneVariable,
SceneGridLayout,
SceneVariableSet,
SceneComponentProps,
SceneGridItemStateLike,
SceneGridItemLike,
sceneGraph,
MultiValueVariable,
VariableValueSingle,
LocalValueVariable,
} from '@grafana/scenes';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
interface PanelRepeaterGridItemState extends SceneGridItemStateLike {
source: VizPanel;
repeatedPanels?: VizPanel[];
variableName: string;
itemHeight?: number;
repeatDirection?: RepeatDirection | string;
maxPerRow?: number;
}
export type RepeatDirection = 'v' | 'h';
export class PanelRepeaterGridItem extends SceneObjectBase<PanelRepeaterGridItemState> implements SceneGridItemLike {
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [this.state.variableName],
onVariableUpdatesCompleted: this._onVariableChanged.bind(this),
});
private _isWaitingForVariables = false;
public constructor(state: PanelRepeaterGridItemState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState)));
// If we our variable is ready we can process repeats on activation
if (sceneGraph.hasVariableDependencyInLoadingState(this)) {
this._isWaitingForVariables = true;
} else {
this._performRepeat();
}
}
private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void {
if (dependencyChanged) {
this._performRepeat();
return;
}
// If we are waiting for variables and the variable is no longer loading then we are ready to repeat as well
if (this._isWaitingForVariables && !sceneGraph.hasVariableDependencyInLoadingState(this)) {
this._isWaitingForVariables = false;
this._performRepeat();
}
}
/**
* Uses the current repeat item count to calculate the user intended desired itemHeight
*/
private _handleGridResize(newState: PanelRepeaterGridItemState, prevState: PanelRepeaterGridItemState) {
const itemCount = this.state.repeatedPanels?.length ?? 1;
const stateChange: Partial<PanelRepeaterGridItemState> = {};
// Height changed
if (newState.height === prevState.height) {
return;
}
if (this.getRepeatDirection() === 'v') {
const itemHeight = Math.ceil(newState.height! / itemCount);
stateChange.itemHeight = itemHeight;
} else {
const rowCount = Math.ceil(itemCount / this.getMaxPerRow());
stateChange.itemHeight = Math.ceil(newState.height! / rowCount);
}
if (stateChange.itemHeight !== this.state.itemHeight) {
this.setState(stateChange);
}
}
private _performRepeat() {
const variable = sceneGraph.lookupVariable(this.state.variableName, this);
if (!variable) {
console.error('SceneGridItemRepeater: Variable not found');
return;
}
if (!(variable instanceof MultiValueVariable)) {
console.error('PanelRepeaterGridItem: Variable is not a MultiValueVariable');
return;
}
const panelToRepeat = this.state.source;
const { values, texts } = this.getVariableValues(variable);
const repeatedPanels: VizPanel[] = [];
// Loop through variable values and create repeates
for (let index = 0; index < values.length; index++) {
const clone = panelToRepeat.clone({
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({ name: variable.state.name, value: values[index], text: String(texts[index]) }),
],
}),
key: `${panelToRepeat.state.key}-clone-${index}`,
});
repeatedPanels.push(clone);
}
const direction = this.getRepeatDirection();
const stateChange: Partial<PanelRepeaterGridItemState> = { repeatedPanels: repeatedPanels };
const itemHeight = this.state.itemHeight ?? 10;
const maxPerRow = this.getMaxPerRow();
if (direction === 'h') {
const rowCount = Math.ceil(repeatedPanels.length / maxPerRow);
stateChange.height = rowCount * itemHeight;
} else {
stateChange.height = repeatedPanels.length * itemHeight;
}
this.setState(stateChange);
// In case we updated our height the grid layout needs to be update
if (this.parent instanceof SceneGridLayout) {
this.parent!.forceRender();
}
}
private getVariableValues(variable: MultiValueVariable): {
values: VariableValueSingle[];
texts: VariableValueSingle[];
} {
const { value, text, options } = variable.state;
if (variable.hasAllValue()) {
return {
values: options.map((o) => o.value),
texts: options.map((o) => o.label),
};
}
return {
values: Array.isArray(value) ? value : [value],
texts: Array.isArray(text) ? text : [text],
};
}
private getMaxPerRow(): number {
return this.state.maxPerRow ?? 4;
}
public getRepeatDirection(): RepeatDirection {
return this.state.repeatDirection === 'v' ? 'v' : 'h';
}
public getClassName() {
return 'panel-repeater-grid-item';
}
public static Component = ({ model }: SceneComponentProps<PanelRepeaterGridItem>) => {
const { repeatedPanels, itemHeight } = model.useState();
const itemCount = repeatedPanels?.length ?? 0;
const layoutStyle = useLayoutStyle(model.getRepeatDirection(), itemCount, model.getMaxPerRow(), itemHeight ?? 10);
if (!repeatedPanels) {
return null;
}
return (
<div className={layoutStyle}>
{repeatedPanels.map((panel) => (
<div className={itemStyle} key={panel.state.key}>
<panel.Component model={panel} key={panel.state.key} />
</div>
))}
</div>
);
};
}
function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow: number, itemHeight: number) {
return useMemo(() => {
const theme = config.theme2;
// In mobile responsive layout we have to calculate the absolute height
const mobileHeight = itemHeight * GRID_CELL_HEIGHT * itemCount + (itemCount - 1) * GRID_CELL_VMARGIN;
if (direction === 'h') {
const rowCount = Math.ceil(itemCount / maxPerRow);
const columnCount = Math.ceil(itemCount / rowCount);
return css({
display: 'grid',
height: '100%',
width: '100%',
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gridTemplateRows: `repeat(${rowCount}, 1fr)`,
gridColumnGap: theme.spacing(1),
gridRowGap: theme.spacing(1),
[theme.breakpoints.down('md')]: {
display: 'flex',
flexDirection: 'column',
height: mobileHeight,
},
});
}
// Vertical is a bit simpler
return css({
display: 'flex',
height: '100%',
width: '100%',
flexDirection: 'column',
gap: theme.spacing(1),
[theme.breakpoints.down('md')]: {
height: mobileHeight,
},
});
}, [direction, itemCount, maxPerRow, itemHeight]);
}
const itemStyle = css({
display: 'flex',
flexGrow: 1,
position: 'relative',
});

View File

@ -3,7 +3,7 @@ import { advanceTo, clear } from 'jest-date-mock';
import { dateTime } from '@grafana/data';
import { SceneCanvasText, SceneFlexItem, SceneFlexLayout, SceneTimeRange } from '@grafana/scenes';
import { activateFullSceneTree } from '../utils/utils';
import { activateFullSceneTree } from '../utils/test-utils';
import { PanelTimeRange } from './PanelTimeRange';

View File

@ -8,7 +8,8 @@ import {
SceneObjectState,
} from '@grafana/scenes';
import { activateFullSceneTree, getVizPanelKeyForPanelId } from '../utils/utils';
import { activateFullSceneTree } from '../utils/test-utils';
import { getVizPanelKeyForPanelId } from '../utils/utils';
import { ShareQueryDataProvider } from './ShareQueryDataProvider';

View File

@ -18,12 +18,13 @@ import { createPanelJSONFixture } from 'app/features/dashboard/state/__fixtures_
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider';
import {
createDashboardSceneFromDashboardModel,
buildSceneFromPanelModel,
buildGridItemForPanel,
createSceneVariableFromVariableModel,
} from './transformSaveModelToScene';
@ -214,6 +215,7 @@ describe('DashboardLoader', () => {
defaults: {
unit: 'none',
},
overrides: [],
},
pluginVersion: '1.0.0',
transformations: [
@ -235,24 +237,26 @@ describe('DashboardLoader', () => {
},
],
};
const vizPanelSceneObject = buildSceneFromPanelModel(new PanelModel(panel));
const vizPanelItelf = vizPanelSceneObject.state.body as VizPanel;
expect(vizPanelItelf?.state.title).toBe('test');
expect(vizPanelItelf?.state.pluginId).toBe('test-plugin');
expect(vizPanelSceneObject.state.x).toEqual(0);
expect(vizPanelSceneObject.state.y).toEqual(0);
expect(vizPanelSceneObject.state.width).toEqual(12);
expect(vizPanelSceneObject.state.height).toEqual(8);
expect(vizPanelItelf?.state.options).toEqual(panel.options);
expect(vizPanelItelf?.state.fieldConfig).toEqual(panel.fieldConfig);
expect(vizPanelItelf?.state.pluginVersion).toBe('1.0.0');
const { gridItem, vizPanel } = buildGridItemForTest(panel);
expect(gridItem.state.x).toEqual(0);
expect(gridItem.state.y).toEqual(0);
expect(gridItem.state.width).toEqual(12);
expect(gridItem.state.height).toEqual(8);
expect(vizPanel.state.title).toBe('test');
expect(vizPanel.state.pluginId).toBe('test-plugin');
expect(vizPanel.state.options).toEqual(panel.options);
expect(vizPanel.state.fieldConfig).toEqual(panel.fieldConfig);
expect(vizPanel.state.pluginVersion).toBe('1.0.0');
expect(((vizPanel.state.$data as SceneDataTransformer)?.state.$data as SceneQueryRunner).state.queries).toEqual(
panel.targets
);
expect(
((vizPanelItelf.state.$data as SceneDataTransformer)?.state.$data as SceneQueryRunner).state.queries
).toEqual(panel.targets);
expect(
((vizPanelItelf.state.$data as SceneDataTransformer)?.state.$data as SceneQueryRunner).state.maxDataPoints
((vizPanel.state.$data as SceneDataTransformer)?.state.$data as SceneQueryRunner).state.maxDataPoints
).toEqual(100);
expect((vizPanelItelf.state.$data as SceneDataTransformer)?.state.transformations).toEqual(panel.transformations);
expect((vizPanel.state.$data as SceneDataTransformer)?.state.transformations).toEqual(panel.transformations);
});
it('should initalize the VizPanel without title and transparent true', () => {
@ -263,8 +267,7 @@ describe('DashboardLoader', () => {
transparent: true,
};
const gridItem = buildSceneFromPanelModel(new PanelModel(panel));
const vizPanel = gridItem.state.body as VizPanel;
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.displayMode).toEqual('transparent');
expect(vizPanel.state.hoverHeader).toEqual(true);
@ -277,8 +280,7 @@ describe('DashboardLoader', () => {
timeShift: '1d',
};
const gridItem = buildSceneFromPanelModel(new PanelModel(panel));
const vizPanel = gridItem.state.body as VizPanel;
const { vizPanel } = buildGridItemForTest(panel);
const timeRange = vizPanel.state.$timeRange as PanelTimeRange;
expect(timeRange).toBeInstanceOf(PanelTimeRange);
@ -296,8 +298,7 @@ describe('DashboardLoader', () => {
targets: [{ refId: 'A', panelId: 10 }],
};
const vizPanel = buildSceneFromPanelModel(new PanelModel(panel)).state.body as VizPanel;
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.$data).toBeInstanceOf(ShareQueryDataProvider);
});
@ -314,11 +315,31 @@ describe('DashboardLoader', () => {
skipDataQuery: true,
}).meta;
const gridItem = buildSceneFromPanelModel(new PanelModel(panel));
const vizPanel = gridItem.state.body as VizPanel;
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.$data).toBeUndefined();
});
it('When repeat is set should build PanelRepeaterGridItem', () => {
const panel = {
title: '',
type: 'text-plugin-34',
gridPos: { x: 0, y: 0, w: 8, h: 8 },
repeat: 'server',
repeatDirection: 'v',
maxPerRow: 8,
};
const gridItem = buildGridItemForPanel(new PanelModel(panel));
const repeater = gridItem as PanelRepeaterGridItem;
expect(repeater.state.maxPerRow).toBe(8);
expect(repeater.state.variableName).toBe('server');
expect(repeater.state.width).toBe(8);
expect(repeater.state.height).toBe(8);
expect(repeater.state.repeatDirection).toBe('v');
expect(repeater.state.maxPerRow).toBe(8);
});
});
describe('when creating variables objects', () => {
@ -595,3 +616,12 @@ describe('DashboardLoader', () => {
});
});
});
function buildGridItemForTest(saveModel: Partial<Panel>): { gridItem: SceneGridItem; vizPanel: VizPanel } {
const gridItem = buildGridItemForPanel(new PanelModel(saveModel));
if (gridItem instanceof SceneGridItem) {
return { gridItem, vizPanel: gridItem.state.body as VizPanel };
}
throw new Error('buildGridItemForPanel to return SceneGridItem');
}

View File

@ -25,6 +25,7 @@ import {
VizPanelMenu,
behaviors,
VizPanelState,
SceneGridItemLike,
} from '@grafana/scenes';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { DashboardDTO } from 'app/types';
@ -32,6 +33,7 @@ import { DashboardDTO } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { getVizPanelKeyForPanelId } from '../utils/utils';
@ -51,14 +53,14 @@ export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
return createDashboardSceneFromDashboardModel(oldModel);
}
export function createSceneObjectsForPanels(oldPanels: PanelModel[]): Array<SceneGridItem | SceneGridRow> {
export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
// collects all panels and rows
const panels: Array<SceneGridItem | SceneGridRow> = [];
const panels: SceneGridItemLike[] = [];
// indicates expanded row that's currently processed
let currentRow: PanelModel | null = null;
// collects panels in the currently processed, expanded row
let currentRowPanels: SceneGridItem[] = [];
let currentRowPanels: SceneGridItemLike[] = [];
for (const panel of oldPanels) {
if (panel.type === 'row') {
@ -70,7 +72,7 @@ export function createSceneObjectsForPanels(oldPanels: PanelModel[]): Array<Scen
title: panel.title,
isCollapsed: true,
y: panel.gridPos.y,
children: panel.panels ? panel.panels.map(buildSceneFromPanelModel) : [],
children: panel.panels ? panel.panels.map(buildGridItemForPanel) : [],
})
);
} else {
@ -106,7 +108,7 @@ export function createSceneObjectsForPanels(oldPanels: PanelModel[]): Array<Scen
});
panels.push(gridItem);
} else {
const panelObject = buildSceneFromPanelModel(panel);
const panelObject = buildGridItemForPanel(panel);
// when processing an expanded row, collect its panels
if (currentRow) {
@ -246,7 +248,7 @@ export function createSceneVariableFromVariableModel(variable: VariableModel): S
}
}
export function buildSceneFromPanelModel(panel: PanelModel): SceneGridItem {
export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike {
const vizPanelState: VizPanelState = {
key: getVizPanelKeyForPanelId(panel.id),
title: panel.title,
@ -271,6 +273,24 @@ export function buildSceneFromPanelModel(panel: PanelModel): SceneGridItem {
});
}
if (panel.repeat) {
const repeatDirection = panel.repeatDirection ?? 'h';
return new PanelRepeaterGridItem({
key: `grid-item-${panel.id}`,
x: panel.gridPos.x,
y: panel.gridPos.y,
width: repeatDirection === 'h' ? 24 : panel.gridPos.w,
height: panel.gridPos.h,
itemHeight: panel.gridPos.h,
source: new VizPanel(vizPanelState),
variableName: panel.repeat,
repeatedPanels: [],
repeatDirection: panel.repeatDirection,
maxPerRow: panel.maxPerRow,
});
}
return new SceneGridItem({
key: `grid-item-${panel.id}`,
x: panel.gridPos.x,

View File

@ -1,9 +1,9 @@
import { SceneGridItem } from '@grafana/scenes';
import { SceneGridItemLike } from '@grafana/scenes';
import { Panel } from '@grafana/schema';
import { PanelModel } from 'app/features/dashboard/state';
import dashboard_to_load1 from './testfiles/dashboard_to_load1.json';
import { buildSceneFromPanelModel, transformSaveModelToScene } from './transformSaveModelToScene';
import { buildGridItemForPanel, transformSaveModelToScene } from './transformSaveModelToScene';
import { gridItemToPanel, transformSceneToSaveModel } from './transformSceneToSaveModel';
describe('transformSceneToSaveModel', () => {
@ -18,7 +18,7 @@ describe('transformSceneToSaveModel', () => {
describe('Panel options', () => {
it('Given panel with time override', () => {
const gridItem = createVizPanelFromPanelSchema({
const gridItem = buildGridItemFromPanelSchema({
timeFrom: '2h',
timeShift: '1d',
hideTimeOverride: true,
@ -31,14 +31,34 @@ describe('transformSceneToSaveModel', () => {
});
it('transparent panel', () => {
const gridItem = createVizPanelFromPanelSchema({ transparent: true });
const gridItem = buildGridItemFromPanelSchema({ transparent: true });
const saveModel = gridItemToPanel(gridItem);
expect(saveModel.transparent).toBe(true);
});
it('Given panel with repeat', () => {
const gridItem = buildGridItemFromPanelSchema({
title: '',
type: 'text-plugin-34',
gridPos: { x: 1, y: 2, w: 12, h: 8 },
repeat: 'server',
repeatDirection: 'v',
maxPerRow: 8,
});
const saveModel = gridItemToPanel(gridItem);
expect(saveModel.repeat).toBe('server');
expect(saveModel.repeatDirection).toBe('v');
expect(saveModel.maxPerRow).toBe(8);
expect(saveModel.gridPos?.x).toBe(1);
expect(saveModel.gridPos?.y).toBe(2);
expect(saveModel.gridPos?.w).toBe(12);
expect(saveModel.gridPos?.h).toBe(8);
});
});
});
export function createVizPanelFromPanelSchema(panel: Partial<Panel>): SceneGridItem {
return buildSceneFromPanelModel(new PanelModel(panel));
export function buildGridItemFromPanelSchema(panel: Partial<Panel>): SceneGridItemLike {
return buildGridItemForPanel(new PanelModel(panel));
}

View File

@ -1,8 +1,9 @@
import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes';
import { SceneGridItem, SceneGridItemLike, SceneGridLayout, VizPanel } from '@grafana/scenes';
import { Dashboard, defaultDashboard, FieldConfigSource, Panel } from '@grafana/schema';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { getPanelIdForVizPanel } from '../utils/utils';
@ -34,22 +35,43 @@ export function transformSceneToSaveModel(scene: DashboardScene): Dashboard {
return sortedDeepCloneWithoutNulls(dashboard);
}
export function gridItemToPanel(gridItem: SceneGridItem): Panel {
const vizPanel = gridItem.state.body;
if (!(vizPanel instanceof VizPanel)) {
throw new Error('SceneGridItem body expected to be VizPanel');
export function gridItemToPanel(gridItem: SceneGridItemLike): Panel {
let vizPanel: VizPanel | undefined;
let x = 0,
y = 0,
w = 0,
h = 0;
if (gridItem instanceof SceneGridItem) {
if (!(gridItem.state.body instanceof VizPanel)) {
throw new Error('SceneGridItem body expected to be VizPanel');
}
vizPanel = gridItem.state.body;
x = gridItem.state.x ?? 0;
y = gridItem.state.y ?? 0;
w = gridItem.state.width ?? 0;
h = gridItem.state.height ?? 0;
}
if (gridItem instanceof PanelRepeaterGridItem) {
vizPanel = gridItem.state.source;
x = gridItem.state.x ?? 0;
y = gridItem.state.y ?? 0;
w = gridItem.state.width ?? 0;
h = gridItem.state.height ?? 0;
}
if (!vizPanel) {
throw new Error('Unsupported grid item type');
}
const panel: Panel = {
id: getPanelIdForVizPanel(vizPanel),
type: vizPanel.state.pluginId,
title: vizPanel.state.title,
gridPos: {
x: gridItem.state.x ?? 0,
y: gridItem.state.y ?? 0,
w: gridItem.state.width ?? 0,
h: gridItem.state.height ?? 0,
},
gridPos: { x, y, w, h },
options: vizPanel.state.options,
fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? { defaults: {}, overrides: [] },
transformations: [],
@ -68,5 +90,11 @@ export function gridItemToPanel(gridItem: SceneGridItem): Panel {
panel.transparent = true;
}
if (gridItem instanceof PanelRepeaterGridItem) {
panel.repeat = gridItem.state.variableName;
panel.maxPerRow = gridItem.state.maxPerRow;
panel.repeatDirection = gridItem.getRepeatDirection();
}
return panel;
}

View File

@ -1,4 +1,4 @@
import { DeepPartial } from '@grafana/scenes';
import { DeepPartial, SceneDeactivationHandler, SceneObject } from '@grafana/scenes';
import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardDTO } from 'app/types';
@ -38,3 +38,24 @@ export function mockResizeObserver() {
unobserve() {}
};
}
/**
* Useful from tests to simulate mounting a full scene. Children are activated before parents to simulate the real order
* of React mount order and useEffect ordering.
*
*/
export function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler {
const deactivationHandlers: SceneDeactivationHandler[] = [];
scene.forEachChild((child) => {
deactivationHandlers.push(activateFullSceneTree(child));
});
deactivationHandlers.push(scene.activate());
return () => {
for (const handler of deactivationHandlers) {
handler();
}
};
}

View File

@ -1,4 +1,4 @@
import { SceneDeactivationHandler, sceneGraph, SceneObject, VizPanel } from '@grafana/scenes';
import { sceneGraph, SceneObject, VizPanel } from '@grafana/scenes';
export function getVizPanelKeyForPanelId(panelId: number) {
return `panel-${panelId}`;
@ -8,43 +8,45 @@ export function getPanelIdForVizPanel(panel: VizPanel): number {
return parseInt(panel.state.key!.replace('panel-', ''), 10);
}
export function findVizPanelById(scene: SceneObject, id: string | undefined): VizPanel | null {
if (!id) {
/**
* This will also try lookup based on panelId
*/
export function findVizPanelByKey(scene: SceneObject, key: string | undefined): VizPanel | null {
if (!key) {
return null;
}
const panelId = parseInt(id, 10);
const key = getVizPanelKeyForPanelId(panelId);
const panel = findVizPanelInternal(scene, key);
if (panel) {
return panel;
}
const obj = sceneGraph.findObject(scene, (obj) => obj.state.key === key);
if (obj instanceof VizPanel) {
return obj;
// Also try to find by panel id
const id = parseInt(key, 10);
if (isNaN(id)) {
return null;
}
return findVizPanelInternal(scene, getVizPanelKeyForPanelId(id));
}
function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizPanel | null {
if (!key) {
return null;
}
const panel = sceneGraph.findObject(scene, (obj) => obj.state.key === key);
if (panel) {
if (panel instanceof VizPanel) {
return panel;
} else {
throw new Error(`Found panel with key ${key} but it was not a VizPanel`);
}
}
return null;
}
/**
* Useful from tests to simulate mounting a full scene. Children are activated before parents to simulate the real order
* of React mount order and useEffect ordering.
*
*/
export function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler {
const deactivationHandlers: SceneDeactivationHandler[] = [];
scene.forEachChild((child) => {
deactivationHandlers.push(activateFullSceneTree(child));
});
deactivationHandlers.push(scene.activate());
return () => {
for (const handler of deactivationHandlers) {
handler();
}
};
}
/**
* Force re-render children. This is useful in some edge case scenarios when
* children deep down the scene graph needs to be re-rendered when some parent state change.

View File

@ -1090,10 +1090,13 @@ describe('DashboardModel', () => {
panels: [
{ id: 1, type: 'row', collapsed: false, panels: [], gridPos: { x: 0, y: 0, w: 24, h: 6 } },
{ id: 2, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } },
{ id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 }, repeatPanelId: 2 },
{ id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } },
],
});
const panel = dashboard.getPanelById(3);
panel!.repeatPanelId = 1;
expect(dashboard.canEditPanel(panel)).toBe(false);
});

View File

@ -4,6 +4,7 @@ import { getGridWithMultipleTimeRanges } from './gridMultiTimeRange';
import { getMultipleGridLayoutTest } from './gridMultiple';
import { getGridWithMultipleData } from './gridWithMultipleData';
import { getQueryVariableDemo } from './queryVariableDemo';
import { getRepeatingPanelsDemo } from './repeatingPanels';
import { getSceneWithRows } from './sceneWithRows';
import { getTransformationsDemo } from './transformations';
import { getVariablesDemo, getVariablesDemoWithAll } from './variablesDemo';
@ -20,6 +21,7 @@ export function getScenes(): SceneDef[] {
{ title: 'Multiple grid layouts test', getScene: getMultipleGridLayoutTest },
{ title: 'Variables', getScene: getVariablesDemo },
{ title: 'Variables with All values', getScene: getVariablesDemoWithAll },
{ title: 'Variables - Repeating panels', getScene: getRepeatingPanelsDemo },
{ title: 'Query variable', getScene: getQueryVariableDemo },
{ title: 'Transformations demo', getScene: getTransformationsDemo },
];

View File

@ -0,0 +1,108 @@
import {
SceneTimePicker,
SceneTimeRange,
VariableValueSelectors,
SceneVariableSet,
TestVariable,
SceneRefreshPicker,
PanelBuilders,
SceneGridLayout,
SceneControlsSpacer,
} from '@grafana/scenes';
import { VariableRefresh } from '@grafana/schema';
import { PanelRepeaterGridItem } from 'app/features/dashboard-scene/scene/PanelRepeaterGridItem';
import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene';
import { getQueryRunnerWithRandomWalkQuery } from './queries';
/**
* Repeat panels by variable that changes with time refresh. This tries to setup a very specific scenario
* where a variable that is slow (2s) and constantly changing it's result is used to repeat panels. This
* can be used to verify that when the time range change the repeated panels with locally scoped variable value
* still wait for the top level variable to finish loading and the repeat process to complete.
*/
export function getRepeatingPanelsDemo(): DashboardScene {
return new DashboardScene({
title: 'Variables - Repeating panels',
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'server',
query: 'AB',
value: 'server',
text: '',
delayMs: 2000,
isMulti: true,
includeAll: true,
refresh: VariableRefresh.onTimeRangeChanged,
optionsToReturn: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'C' },
],
options: [],
$behaviors: [changeVariable],
}),
],
}),
body: new SceneGridLayout({
isDraggable: true,
isResizable: true,
children: [
new PanelRepeaterGridItem({
variableName: 'server',
x: 0,
y: 0,
width: 24,
height: 8,
itemHeight: 8,
//@ts-expect-error
source: PanelBuilders.timeseries()
.setTitle('server = $server')
.setData(getQueryRunnerWithRandomWalkQuery({ alias: 'server = $server' }))
.build(),
}),
],
}),
$timeRange: new SceneTimeRange(),
actions: [],
controls: [
new VariableValueSelectors({}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
});
}
function changeVariable(variable: TestVariable) {
const sub = variable.subscribeToState((state, old) => {
if (!state.loading && old.loading) {
setTimeout(() => {
if (variable.state.query === 'AB') {
variable.setState({
query: 'ABC',
optionsToReturn: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
});
} else {
variable.setState({
query: 'AB',
optionsToReturn: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
],
});
}
});
return;
}
});
return () => {
sub.unsubscribe();
};
}

View File

@ -59,6 +59,9 @@
transform: translate(0px, 0px) !important;
margin-bottom: $space-md;
}
.panel-repeater-grid-item {
height: auto !important;
}
}
.react-grid-item.react-grid-placeholder {

View File

@ -3939,9 +3939,9 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes@npm:^0.27.0":
version: 0.27.0
resolution: "@grafana/scenes@npm:0.27.0"
"@grafana/scenes@npm:^0.29.0":
version: 0.29.0
resolution: "@grafana/scenes@npm:0.29.0"
dependencies:
"@grafana/e2e-selectors": 10.0.2
react-grid-layout: 1.3.4
@ -3953,7 +3953,7 @@ __metadata:
"@grafana/runtime": 10.0.3
"@grafana/schema": 10.0.3
"@grafana/ui": 10.0.3
checksum: 71b2ea13c6afca0d8d101e9a7d945ebb181ad2acbeb6fa5ca4018a34332a9b1e09a434feea9080665327d3d30a7e4d2542a7491a2f68a944717d56ba014fba25
checksum: 8a91ea0290d54c5c081595e85f853b14af90468da3d85b5cd83e26d24d4fc84cceea9be930aa9239439ff3af7388ae3f5bebe1973214c686f4cf143a64752548
languageName: node
linkType: hard
@ -19229,7 +19229,7 @@ __metadata:
"@grafana/lezer-traceql": 0.0.4
"@grafana/monaco-logql": ^0.0.7
"@grafana/runtime": "workspace:*"
"@grafana/scenes": ^0.27.0
"@grafana/scenes": ^0.29.0
"@grafana/schema": "workspace:*"
"@grafana/tsconfig": ^1.3.0-rc1
"@grafana/ui": "workspace:*"