mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
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:
parent
824f12a993
commit
4aae9d1567
@ -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"],
|
||||||
|
51
public/app/features/scenes/components/NestedScene.test.tsx
Normal file
51
public/app/features/scenes/components/NestedScene.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
119
public/app/features/scenes/components/NestedScene.tsx
Normal file
119
public/app/features/scenes/components/NestedScene.tsx
Normal 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,
|
||||||
|
}),
|
||||||
|
});
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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> = {};
|
||||||
|
@ -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: [
|
||||||
|
16
public/app/features/scenes/scenes/queries.ts
Normal file
16
public/app/features/scenes/scenes/queries.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
62
public/app/features/scenes/scenes/sceneWithRows.tsx
Normal file
62
public/app/features/scenes/scenes/sceneWithRows.tsx
Normal 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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user