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
This commit is contained in:
Torkel Ödegaard 2022-07-18 20:26:10 +02:00 committed by GitHub
parent 824f12a993
commit 4aae9d1567
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 273 additions and 16 deletions

View File

@ -5716,17 +5716,16 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"] [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
], ],
"public/app/features/scenes/core/SceneObjectBase.tsx:5381": [ "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.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"], [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.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"]
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
], ],
"public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],

View File

@ -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(<scene.Component model={scene} />);
}
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();
});
});

View File

@ -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<SceneLayoutState>;
actions?: SceneObject[];
}
export class NestedScene extends SceneObjectBase<NestedSceneState> {
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<NestedScene>) {
const { title, isCollapsed, canCollapse, canRemove, layout, actions } = model.useState();
const styles = useStyles2(getStyles);
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
if (canRemove) {
toolbarActions.push(
<ToolbarButton
icon="times"
variant={'default'}
onClick={model.onRemove}
key="remove-button"
aria-label="Remove scene"
/>
);
}
return (
<div className={styles.row}>
<div className={styles.rowHeader}>
<Stack gap={0}>
<div className={styles.title} role="heading">
{title}
</div>
{canCollapse && (
<div className={styles.toggle}>
<Button
size="sm"
icon={isCollapsed ? 'angle-down' : 'angle-up'}
fill="text"
variant="secondary"
aria-label={isCollapsed ? 'Expand scene' : 'Collapse scene'}
onClick={model.onToggle}
/>
</div>
)}
</Stack>
<div className={styles.actions}>{toolbarActions}</div>
</div>
{!isCollapsed && <layout.Component model={layout} isEditing={isEditing} />}
</div>
);
}
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,
}),
});

View File

@ -15,12 +15,13 @@ import {
SceneEditor, SceneEditor,
SceneObjectList, SceneObjectList,
SceneTimeRange, SceneTimeRange,
isSceneObject,
} from './types'; } from './types';
export abstract class SceneObjectBase<TState extends SceneObjectState = {}> implements SceneObject<TState> { export abstract class SceneObjectBase<TState extends SceneObjectState = {}> implements SceneObject<TState> {
subject = new Subject<TState>(); subject = new Subject<TState>();
state: TState; state: TState;
parent?: SceneObjectBase<any>; parent?: SceneObjectBase<SceneObjectState>;
subs = new Subscription(); subs = new Subscription();
isActive?: boolean; isActive?: boolean;
events = new EventBusSrv(); events = new EventBusSrv();
@ -52,13 +53,13 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = {}> impl
private setParent() { private setParent() {
for (const propValue of Object.values(this.state)) { for (const propValue of Object.values(this.state)) {
if (propValue instanceof SceneObjectBase) { if (isSceneObject(propValue)) {
propValue.parent = this; propValue.parent = this;
} }
if (Array.isArray(propValue)) { if (Array.isArray(propValue)) {
for (const child of propValue) { for (const child of propValue) {
if (child instanceof SceneObjectBase) { if (isSceneObject(child)) {
child.parent = this; child.parent = this;
} }
} }

View File

@ -74,7 +74,7 @@ export interface SceneObject<TState extends SceneObjectState = SceneObjectState>
Editor(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null; Editor(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null;
} }
export type SceneObjectList<T = SceneObjectState> = Array<SceneObject<T>>; export type SceneObjectList<T extends SceneObjectState | SceneLayoutState = SceneObjectState> = Array<SceneObject<T>>;
export interface SceneLayoutState extends SceneObjectState { export interface SceneLayoutState extends SceneObjectState {
children: SceneObjectList; children: SceneObjectList;
@ -117,3 +117,9 @@ export interface SceneObjectWithUrlSync extends SceneObject {
export function isSceneObjectWithUrlSync(obj: any): obj is SceneObjectWithUrlSync { export function isSceneObjectWithUrlSync(obj: any): obj is SceneObjectWithUrlSync {
return obj.getUrlState !== undefined; return obj.getUrlState !== undefined;
} }
export function isSceneLayoutObject(
obj: SceneObject<SceneObjectState | SceneLayoutState>
): obj is SceneObject<SceneLayoutState> {
return 'children' in obj.state && obj.state.children !== undefined;
}

View File

@ -2,9 +2,10 @@ import { Scene } from '../components/Scene';
import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo'; import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo';
import { getNestedScene } from './nested'; import { getNestedScene } from './nested';
import { getSceneWithRows } from './sceneWithRows';
export function getScenes(): Scene[] { export function getScenes(): Scene[] {
return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene()]; return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows()];
} }
const cache: Record<string, Scene> = {}; const cache: Record<string, Scene> = {};

View File

@ -1,5 +1,6 @@
import { getDefaultTimeRange } from '@grafana/data'; import { getDefaultTimeRange } from '@grafana/data';
import { NestedScene } from '../components/NestedScene';
import { Scene } from '../components/Scene'; import { Scene } from '../components/Scene';
import { SceneFlexLayout } from '../components/SceneFlexLayout'; import { SceneFlexLayout } from '../components/SceneFlexLayout';
import { SceneTimePicker } from '../components/SceneTimePicker'; import { SceneTimePicker } from '../components/SceneTimePicker';
@ -40,9 +41,10 @@ export function getNestedScene(): Scene {
return scene; return scene;
} }
export function getInnerScene(title: string): Scene { export function getInnerScene(title: string) {
const scene = new Scene({ const scene = new NestedScene({
title: title, title: title,
canRemove: true,
layout: new SceneFlexLayout({ layout: new SceneFlexLayout({
direction: 'row', direction: 'row',
children: [ children: [

View File

@ -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',
},
],
});
}

View File

@ -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;
}