From 4aae9d156719bd5bef76ae55b1834fd2178b2059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 18 Jul 2022 20:26:10 +0200 Subject: [PATCH] Scene: Support for collapsable rows via a nested scene object (#52367) * initial row test * Updated * Row is more of a nested collapsable scene * Updated * Added test for nested scene * Added test for nested scene --- .betterer.results | 17 ++- .../scenes/components/NestedScene.test.tsx | 51 ++++++++ .../scenes/components/NestedScene.tsx | 119 ++++++++++++++++++ .../features/scenes/core/SceneObjectBase.tsx | 7 +- public/app/features/scenes/core/types.ts | 8 +- public/app/features/scenes/scenes/index.tsx | 3 +- public/app/features/scenes/scenes/nested.tsx | 6 +- public/app/features/scenes/scenes/queries.ts | 16 +++ .../features/scenes/scenes/sceneWithRows.tsx | 62 +++++++++ 9 files changed, 273 insertions(+), 16 deletions(-) create mode 100644 public/app/features/scenes/components/NestedScene.test.tsx create mode 100644 public/app/features/scenes/components/NestedScene.tsx create mode 100644 public/app/features/scenes/scenes/queries.ts create mode 100644 public/app/features/scenes/scenes/sceneWithRows.tsx 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; +}