diff --git a/.betterer.results b/.betterer.results
index ceabba5c265..914a3afa637 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -5716,17 +5716,16 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/scenes/core/SceneObjectBase.tsx:5381": [
- [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
+ [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
- [0, 0, 0, "Do not use any type assertions.", "2"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
+ [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"],
- [0, 0, 0, "Do not use any type assertions.", "5"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "6"],
- [0, 0, 0, "Do not use any type assertions.", "7"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "8"],
- [0, 0, 0, "Do not use any type assertions.", "9"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "10"]
+ [0, 0, 0, "Unexpected any. Specify a different type.", "5"],
+ [0, 0, 0, "Do not use any type assertions.", "6"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "7"],
+ [0, 0, 0, "Do not use any type assertions.", "8"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "9"]
],
"public/app/features/scenes/core/SceneTimeRange.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
diff --git a/public/app/features/scenes/components/NestedScene.test.tsx b/public/app/features/scenes/components/NestedScene.test.tsx
new file mode 100644
index 00000000000..7a400e8447d
--- /dev/null
+++ b/public/app/features/scenes/components/NestedScene.test.tsx
@@ -0,0 +1,51 @@
+import { screen, render } from '@testing-library/react';
+import React from 'react';
+
+import { NestedScene } from './NestedScene';
+import { Scene } from './Scene';
+import { SceneCanvasText } from './SceneCanvasText';
+import { SceneFlexLayout } from './SceneFlexLayout';
+
+function setup() {
+ const scene = new Scene({
+ title: 'Hello',
+ layout: new SceneFlexLayout({
+ children: [
+ new NestedScene({
+ title: 'Nested title',
+ canRemove: true,
+ canCollapse: true,
+ layout: 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
new file mode 100644
index 00000000000..b36d40d7b40
--- /dev/null
+++ b/public/app/features/scenes/components/NestedScene.tsx
@@ -0,0 +1,119 @@
+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,
+ SceneObjectState,
+ SceneLayoutState,
+ SceneComponentProps,
+ isSceneLayoutObject,
+} from '../core/types';
+
+interface NestedSceneState extends SceneObjectState {
+ title: string;
+ isCollapsed?: boolean;
+ canCollapse?: boolean;
+ canRemove?: boolean;
+ layout: SceneObject;
+ actions?: SceneObject[];
+}
+
+export class NestedScene extends SceneObjectBase {
+ static Component = NestedSceneRenderer;
+
+ onToggle = () => {
+ this.setState({
+ isCollapsed: !this.state.isCollapsed,
+ size: {
+ ...this.state.size,
+ ySizing: this.state.isCollapsed ? 'fill' : 'content',
+ },
+ });
+ };
+
+ /** Removes itself from it's parent's children array */
+ onRemove = () => {
+ const parent = this.parent!;
+ if (isSceneLayoutObject(parent)) {
+ parent.setState({
+ children: parent.state.children.filter((x) => x !== this),
+ });
+ }
+ };
+}
+
+export function NestedSceneRenderer({ model, isEditing }: SceneComponentProps) {
+ const { title, isCollapsed, canCollapse, canRemove, layout, 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/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx
index 56eca2e0b0e..28bdd477b78 100644
--- a/public/app/features/scenes/core/SceneObjectBase.tsx
+++ b/public/app/features/scenes/core/SceneObjectBase.tsx
@@ -15,12 +15,13 @@ import {
SceneEditor,
SceneObjectList,
SceneTimeRange,
+ isSceneObject,
} from './types';
export abstract class SceneObjectBase implements SceneObject {
subject = new Subject();
state: TState;
- parent?: SceneObjectBase;
+ parent?: SceneObjectBase;
subs = new Subscription();
isActive?: boolean;
events = new EventBusSrv();
@@ -52,13 +53,13 @@ export abstract class SceneObjectBase impl
private setParent() {
for (const propValue of Object.values(this.state)) {
- if (propValue instanceof SceneObjectBase) {
+ if (isSceneObject(propValue)) {
propValue.parent = this;
}
if (Array.isArray(propValue)) {
for (const child of propValue) {
- if (child instanceof SceneObjectBase) {
+ if (isSceneObject(child)) {
child.parent = this;
}
}
diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts
index bb32a56b38f..673bdb44490 100644
--- a/public/app/features/scenes/core/types.ts
+++ b/public/app/features/scenes/core/types.ts
@@ -74,7 +74,7 @@ export interface SceneObject
Editor(props: SceneComponentProps>): React.ReactElement | null;
}
-export type SceneObjectList = Array>;
+export type SceneObjectList = Array>;
export interface SceneLayoutState extends SceneObjectState {
children: SceneObjectList;
@@ -117,3 +117,9 @@ export interface SceneObjectWithUrlSync extends SceneObject {
export function isSceneObjectWithUrlSync(obj: any): obj is SceneObjectWithUrlSync {
return obj.getUrlState !== undefined;
}
+
+export function isSceneLayoutObject(
+ obj: SceneObject
+): obj is SceneObject {
+ return 'children' in obj.state && obj.state.children !== undefined;
+}
diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx
index 150d6b2b77b..c28184ab78d 100644
--- a/public/app/features/scenes/scenes/index.tsx
+++ b/public/app/features/scenes/scenes/index.tsx
@@ -2,9 +2,10 @@ import { Scene } from '../components/Scene';
import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo';
import { getNestedScene } from './nested';
+import { getSceneWithRows } from './sceneWithRows';
export function getScenes(): Scene[] {
- return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene()];
+ return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows()];
}
const cache: Record = {};
diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx
index 64076f1ca4c..77684fc4684 100644
--- a/public/app/features/scenes/scenes/nested.tsx
+++ b/public/app/features/scenes/scenes/nested.tsx
@@ -1,5 +1,6 @@
import { getDefaultTimeRange } from '@grafana/data';
+import { NestedScene } from '../components/NestedScene';
import { Scene } from '../components/Scene';
import { SceneFlexLayout } from '../components/SceneFlexLayout';
import { SceneTimePicker } from '../components/SceneTimePicker';
@@ -40,9 +41,10 @@ export function getNestedScene(): Scene {
return scene;
}
-export function getInnerScene(title: string): Scene {
- const scene = new Scene({
+export function getInnerScene(title: string) {
+ const scene = new NestedScene({
title: title,
+ canRemove: true,
layout: new SceneFlexLayout({
direction: 'row',
children: [
diff --git a/public/app/features/scenes/scenes/queries.ts b/public/app/features/scenes/scenes/queries.ts
new file mode 100644
index 00000000000..82a60eae3fe
--- /dev/null
+++ b/public/app/features/scenes/scenes/queries.ts
@@ -0,0 +1,16 @@
+import { SceneQueryRunner } from '../querying/SceneQueryRunner';
+
+export function getQueryRunnerWithRandomWalkQuery() {
+ return new SceneQueryRunner({
+ queries: [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'gdev-testdata',
+ type: 'testdata',
+ },
+ scenarioId: 'random_walk',
+ },
+ ],
+ });
+}
diff --git a/public/app/features/scenes/scenes/sceneWithRows.tsx b/public/app/features/scenes/scenes/sceneWithRows.tsx
new file mode 100644
index 00000000000..a45282778d0
--- /dev/null
+++ b/public/app/features/scenes/scenes/sceneWithRows.tsx
@@ -0,0 +1,62 @@
+import { getDefaultTimeRange } from '@grafana/data';
+
+import { NestedScene } from '../components/NestedScene';
+import { Scene } from '../components/Scene';
+import { SceneFlexLayout } from '../components/SceneFlexLayout';
+import { SceneTimePicker } from '../components/SceneTimePicker';
+import { VizPanel } from '../components/VizPanel';
+import { SceneTimeRange } from '../core/SceneTimeRange';
+import { SceneEditManager } from '../editor/SceneEditManager';
+
+import { getQueryRunnerWithRandomWalkQuery } from './queries';
+
+export function getSceneWithRows(): Scene {
+ const scene = new Scene({
+ title: 'Scene with rows',
+ layout: new SceneFlexLayout({
+ direction: 'column',
+ children: [
+ new NestedScene({
+ title: 'Overview',
+ canCollapse: true,
+ layout: new SceneFlexLayout({
+ direction: 'row',
+ children: [
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fill height',
+ }),
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fill height',
+ }),
+ ],
+ }),
+ }),
+ new NestedScene({
+ title: 'More server details',
+ canCollapse: true,
+ layout: new SceneFlexLayout({
+ direction: 'row',
+ children: [
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fill height',
+ }),
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fill height',
+ }),
+ ],
+ }),
+ }),
+ ],
+ }),
+ $editor: new SceneEditManager({}),
+ $timeRange: new SceneTimeRange(getDefaultTimeRange()),
+ $data: getQueryRunnerWithRandomWalkQuery(),
+ actions: [new SceneTimePicker({})],
+ });
+
+ return scene;
+}