mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Unified Node Graph Container (#72558)
* Improve naming; fix tests * Pass countWarning to titleItems * Center warning text * Revert variable name * Switch from arrow function to direct assignment * Add ability to make component controlled * Replace remaining isOpen with collapsed * Fix test; update story and docs * Add test; add uncontrolled functionality
This commit is contained in:
parent
d293b08e52
commit
19ae937aa8
@ -512,24 +512,37 @@ The panel can be collapsed/expanded by clicking on the chevron or the title.
|
|||||||
> _Note: `collapsible` and `hoverHeader` props are mutually exclusive and cannot be used in the same panel._
|
> _Note: `collapsible` and `hoverHeader` props are mutually exclusive and cannot be used in the same panel._
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<PanelChrome title="My awesome panel title" width={400} height={200} collapsible={true}>
|
function Container() {
|
||||||
{(innerwidth, innerheight) => {
|
const [isCollapsed, setCollapsed] = useState(true);
|
||||||
return (
|
|
||||||
<div
|
return (
|
||||||
style={{
|
<PanelChrome
|
||||||
width: innerwidth,
|
title="My awesome panel title"
|
||||||
height: innerheight,
|
width={400}
|
||||||
background: 'rgba(230,0,0,0.05)',
|
height={200}
|
||||||
display: 'flex',
|
collapsible={true}
|
||||||
alignItems: 'center',
|
collapsed={isCollapsed}
|
||||||
justifyContent: 'center',
|
onToggleCollapse={(isCollapsed) => setCollapsed(isCollapsed)}
|
||||||
}}
|
>
|
||||||
>
|
{(innerwidth, innerheight) => {
|
||||||
Content
|
return (
|
||||||
</div>
|
<div
|
||||||
);
|
style={{
|
||||||
}}
|
width: innerwidth,
|
||||||
</PanelChrome>
|
height: innerheight,
|
||||||
|
background: 'rgba(230,0,0,0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PanelChrome>
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
<Preview>
|
<Preview>
|
||||||
|
@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions';
|
|||||||
import { Meta, StoryFn } from '@storybook/react';
|
import { Meta, StoryFn } from '@storybook/react';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import React, { CSSProperties, useState, ReactNode } from 'react';
|
import React, { CSSProperties, useState, ReactNode } from 'react';
|
||||||
import { useInterval } from 'react-use';
|
import { useInterval, useToggle } from 'react-use';
|
||||||
|
|
||||||
import { LoadingState } from '@grafana/data';
|
import { LoadingState } from '@grafana/data';
|
||||||
import { Button, Icon, PanelChrome, PanelChromeProps, RadioButtonGroup } from '@grafana/ui';
|
import { Button, Icon, PanelChrome, PanelChromeProps, RadioButtonGroup } from '@grafana/ui';
|
||||||
@ -14,6 +14,9 @@ import { Menu } from '../Menu/Menu';
|
|||||||
|
|
||||||
import mdx from './PanelChrome.mdx';
|
import mdx from './PanelChrome.mdx';
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 400;
|
||||||
|
const PANEL_HEIGHT = 150;
|
||||||
|
|
||||||
const meta: Meta<typeof PanelChrome> = {
|
const meta: Meta<typeof PanelChrome> = {
|
||||||
title: 'Visualizations/PanelChrome',
|
title: 'Visualizations/PanelChrome',
|
||||||
component: PanelChrome,
|
component: PanelChrome,
|
||||||
@ -39,8 +42,8 @@ function getContentStyle(): CSSProperties {
|
|||||||
|
|
||||||
function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
|
function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
|
||||||
const props: PanelChromeProps = {
|
const props: PanelChromeProps = {
|
||||||
width: 400,
|
width: PANEL_WIDTH,
|
||||||
height: 150,
|
height: PANEL_HEIGHT,
|
||||||
children: () => undefined,
|
children: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,6 +60,33 @@ function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCollapsiblePanel(name: string, overrides?: Partial<PanelChromeProps>) {
|
||||||
|
const props: PanelChromeProps = {
|
||||||
|
width: PANEL_WIDTH,
|
||||||
|
height: PANEL_HEIGHT,
|
||||||
|
children: () => undefined,
|
||||||
|
collapsible: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
merge(props, overrides);
|
||||||
|
|
||||||
|
const contentStyle = getContentStyle();
|
||||||
|
|
||||||
|
const ControlledCollapseComponent = () => {
|
||||||
|
const [collapsed, toggleCollapsed] = useToggle(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelChrome {...props} collapsed={collapsed} onToggleCollapse={toggleCollapsed}>
|
||||||
|
{(innerWidth, innerHeight) => {
|
||||||
|
return <div style={{ width: innerWidth, height: innerHeight, ...contentStyle }}>{name}</div>;
|
||||||
|
}}
|
||||||
|
</PanelChrome>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ControlledCollapseComponent />;
|
||||||
|
}
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Item label="View" icon="eye" />
|
<Menu.Item label="View" icon="eye" />
|
||||||
@ -215,7 +245,7 @@ export const Examples = () => {
|
|||||||
/>,
|
/>,
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
{renderPanel('Collapsible panel', {
|
{renderCollapsiblePanel('Collapsible panel', {
|
||||||
title: 'Default title',
|
title: 'Default title',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
})}
|
})}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { screen, render, fireEvent } from '@testing-library/react';
|
import { screen, render, fireEvent } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useToggle } from 'react-use';
|
||||||
|
|
||||||
import { LoadingState } from '@grafana/data';
|
import { LoadingState } from '@grafana/data';
|
||||||
|
|
||||||
@ -18,6 +19,27 @@ const setup = (propOverrides?: Partial<PanelChromeProps>) => {
|
|||||||
return render(<PanelChrome {...props} />);
|
return render(<PanelChrome {...props} />);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setupWithToggleCollapsed = (propOverrides?: Partial<PanelChromeProps>) => {
|
||||||
|
const props: PanelChromeProps = {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
children: (innerWidth, innerHeight) => {
|
||||||
|
return <div style={{ width: innerWidth, height: innerHeight, color: 'pink' }}>Panel's Content</div>;
|
||||||
|
},
|
||||||
|
collapsible: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
const ControlledCollapseComponent = () => {
|
||||||
|
const [collapsed, toggleCollapsed] = useToggle(false);
|
||||||
|
|
||||||
|
return <PanelChrome {...props} collapsed={collapsed} onToggleCollapse={toggleCollapsed} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(<ControlledCollapseComponent />);
|
||||||
|
};
|
||||||
|
|
||||||
it('renders an empty panel with required props only', () => {
|
it('renders an empty panel with required props only', () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
@ -125,8 +147,25 @@ it('renders streaming indicator in the panel header if loadingState is streaming
|
|||||||
expect(screen.getByTestId('panel-streaming')).toBeInTheDocument();
|
expect(screen.getByTestId('panel-streaming')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapes the panel when user clicks on the chevron or the title', () => {
|
it('collapses the controlled panel when user clicks on the chevron or the title', () => {
|
||||||
setup({ collapsible: true, title: 'Default title' });
|
setupWithToggleCollapsed({ title: 'Default title' });
|
||||||
|
|
||||||
|
expect(screen.getByText("Panel's Content")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const button = screen.getByText('Default title');
|
||||||
|
// collapse button should have same aria-controls as the panel's content
|
||||||
|
expect(button.getAttribute('aria-controls')).toBe(button.parentElement?.parentElement?.nextElementSibling?.id);
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Panel's Content")).not.toBeInTheDocument();
|
||||||
|
// aria-controls should be removed when panel is collapsed
|
||||||
|
expect(button).not.toHaveAttribute('aria-controlls');
|
||||||
|
expect(button.parentElement?.parentElement?.nextElementSibling?.id).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses the uncontrolled panel when user clicks on the chevron or the title', () => {
|
||||||
|
setup({ title: 'Default title', collapsible: true });
|
||||||
|
|
||||||
expect(screen.getByText("Panel's Content")).toBeInTheDocument();
|
expect(screen.getByText("Panel's Content")).toBeInTheDocument();
|
||||||
|
|
||||||
|
@ -71,12 +71,19 @@ interface AutoSize extends BaseProps {
|
|||||||
|
|
||||||
interface Collapsible {
|
interface Collapsible {
|
||||||
collapsible: boolean;
|
collapsible: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
/**
|
||||||
|
* callback when collapsing or expanding the panel
|
||||||
|
*/
|
||||||
|
onToggleCollapse?: (collapsed: boolean) => void;
|
||||||
hoverHeader?: never;
|
hoverHeader?: never;
|
||||||
hoverHeaderOffset?: never;
|
hoverHeaderOffset?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HoverHeader {
|
interface HoverHeader {
|
||||||
collapsible?: never;
|
collapsible?: never;
|
||||||
|
collapsed?: never;
|
||||||
|
onToggleCollapse?: never;
|
||||||
hoverHeader?: boolean;
|
hoverHeader?: boolean;
|
||||||
hoverHeaderOffset?: number;
|
hoverHeaderOffset?: number;
|
||||||
}
|
}
|
||||||
@ -111,14 +118,21 @@ export function PanelChrome({
|
|||||||
onCancelQuery,
|
onCancelQuery,
|
||||||
onOpenMenu,
|
onOpenMenu,
|
||||||
collapsible = false,
|
collapsible = false,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
}: PanelChromeProps) {
|
}: PanelChromeProps) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const panelContentId = useId();
|
const panelContentId = useId();
|
||||||
|
|
||||||
|
const hasHeader = !hoverHeader;
|
||||||
|
|
||||||
const [isOpen, toggleOpen] = useToggle(true);
|
const [isOpen, toggleOpen] = useToggle(true);
|
||||||
|
|
||||||
const hasHeader = !hoverHeader;
|
// if collapsed is not defined, then component is uncontrolled and state is managed internally
|
||||||
|
if (collapsed === undefined) {
|
||||||
|
collapsed = !isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
// hover menu is only shown on hover when not on touch devices
|
// hover menu is only shown on hover when not on touch devices
|
||||||
const showOnHoverClass = 'show-on-hover';
|
const showOnHoverClass = 'show-on-hover';
|
||||||
@ -129,7 +143,7 @@ export function PanelChrome({
|
|||||||
padding,
|
padding,
|
||||||
theme,
|
theme,
|
||||||
headerHeight,
|
headerHeight,
|
||||||
isOpen,
|
collapsed,
|
||||||
height,
|
height,
|
||||||
width
|
width
|
||||||
);
|
);
|
||||||
@ -139,7 +153,7 @@ export function PanelChrome({
|
|||||||
cursor: dragClass ? 'move' : 'auto',
|
cursor: dragClass ? 'move' : 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerStyles: CSSProperties = { width, height: isOpen ? height : headerHeight };
|
const containerStyles: CSSProperties = { width, height: !collapsed ? height : headerHeight };
|
||||||
const [ref, { width: loadingBarWidth }] = useMeasure<HTMLDivElement>();
|
const [ref, { width: loadingBarWidth }] = useMeasure<HTMLDivElement>();
|
||||||
|
|
||||||
/** Old property name now maps to actions */
|
/** Old property name now maps to actions */
|
||||||
@ -154,12 +168,17 @@ export function PanelChrome({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.clearButtonStyles}
|
className={styles.clearButtonStyles}
|
||||||
onClick={toggleOpen}
|
onClick={() => {
|
||||||
aria-expanded={isOpen}
|
toggleOpen();
|
||||||
aria-controls={isOpen ? panelContentId : undefined}
|
if (onToggleCollapse) {
|
||||||
|
onToggleCollapse(!collapsed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-controls={!collapsed ? panelContentId : undefined}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name={isOpen ? 'angle-down' : 'angle-right'}
|
name={!collapsed ? 'angle-down' : 'angle-right'}
|
||||||
aria-hidden={!!title}
|
aria-hidden={!!title}
|
||||||
aria-label={!title ? 'toggle collapse panel' : undefined}
|
aria-label={!title ? 'toggle collapse panel' : undefined}
|
||||||
/>
|
/>
|
||||||
@ -265,7 +284,7 @@ export function PanelChrome({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
id={panelContentId}
|
id={panelContentId}
|
||||||
className={cx(styles.content, height === undefined && styles.containNone)}
|
className={cx(styles.content, height === undefined && styles.containNone)}
|
||||||
@ -295,7 +314,7 @@ const getContentStyle = (
|
|||||||
padding: string,
|
padding: string,
|
||||||
theme: GrafanaTheme2,
|
theme: GrafanaTheme2,
|
||||||
headerHeight: number,
|
headerHeight: number,
|
||||||
isOpen: boolean,
|
collapsed: boolean,
|
||||||
height?: number,
|
height?: number,
|
||||||
width?: number
|
width?: number
|
||||||
) => {
|
) => {
|
||||||
@ -314,7 +333,7 @@ const getContentStyle = (
|
|||||||
innerHeight = height - headerHeight - panelPadding - panelBorder;
|
innerHeight = height - headerHeight - panelPadding - panelBorder;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) {
|
if (collapsed) {
|
||||||
innerHeight = headerHeight;
|
innerHeight = headerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,8 +18,8 @@ describe('NodeGraphContainer', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Make sure we only show header in the collapsible
|
// Make sure we only show header and loading bar container from PanelChrome in the collapsible
|
||||||
expect(container.firstChild?.childNodes.length).toBe(1);
|
expect(container.firstChild?.childNodes.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the graph if not with trace view', async () => {
|
it('shows the graph if not with trace view', async () => {
|
||||||
@ -33,7 +33,7 @@ describe('NodeGraphContainer', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.firstChild?.childNodes.length).toBe(2);
|
expect(container.firstChild?.childNodes.length).toBe(3);
|
||||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
await screen.findByLabelText(/Node: tempo-querier/);
|
await screen.findByLabelText(/Node: tempo-querier/);
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,7 @@ import { useToggle, useWindowSize } from 'react-use';
|
|||||||
|
|
||||||
import { applyFieldOverrides, DataFrame, GrafanaTheme2, SplitOpen } from '@grafana/data';
|
import { applyFieldOverrides, DataFrame, GrafanaTheme2, SplitOpen } from '@grafana/data';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
import { Collapse, useStyles2, useTheme2 } from '@grafana/ui';
|
import { useStyles2, useTheme2, PanelChrome } from '@grafana/ui';
|
||||||
|
|
||||||
import { NodeGraph } from '../../../plugins/panel/nodeGraph';
|
import { NodeGraph } from '../../../plugins/panel/nodeGraph';
|
||||||
import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames';
|
import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames';
|
||||||
@ -15,6 +15,8 @@ import { useLinks } from '../utils/links';
|
|||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
warningText: css`
|
warningText: css`
|
||||||
label: warningText;
|
label: warningText;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-size: ${theme.typography.bodySmall.fontSize};
|
font-size: ${theme.typography.bodySmall.fontSize};
|
||||||
color: ${theme.colors.text.secondary};
|
color: ${theme.colors.text.secondary};
|
||||||
`,
|
`,
|
||||||
@ -53,9 +55,10 @@ export function UnconnectedNodeGraphContainer(props: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { nodes } = useCategorizeFrames(frames);
|
const { nodes } = useCategorizeFrames(frames);
|
||||||
const [open, toggleOpen] = useToggle(false);
|
const [collapsed, toggleCollapsed] = useToggle(true);
|
||||||
|
|
||||||
const toggled = () => {
|
const toggled = () => {
|
||||||
toggleOpen();
|
toggleCollapsed();
|
||||||
reportInteraction('grafana_traces_node_graph_panel_clicked', {
|
reportInteraction('grafana_traces_node_graph_panel_clicked', {
|
||||||
datasourceType: datasourceType,
|
datasourceType: datasourceType,
|
||||||
grafana_version: config.buildInfo.version,
|
grafana_version: config.buildInfo.version,
|
||||||
@ -81,12 +84,13 @@ export function UnconnectedNodeGraphContainer(props: Props) {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<PanelChrome
|
||||||
label={<span>Node graph{countWarning} </span>}
|
title={`Node graph`}
|
||||||
collapsible={withTraceView}
|
titleItems={countWarning}
|
||||||
// We allow collapsing this only when it is shown together with trace view.
|
// We allow collapsing this only when it is shown together with trace view.
|
||||||
isOpen={withTraceView ? open : true}
|
collapsible={!!withTraceView}
|
||||||
onToggle={withTraceView ? () => toggled() : undefined}
|
collapsed={withTraceView ? collapsed : false}
|
||||||
|
onToggleCollapse={withTraceView ? toggled : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@ -101,7 +105,7 @@ export function UnconnectedNodeGraphContainer(props: Props) {
|
|||||||
>
|
>
|
||||||
<NodeGraph dataFrames={frames} getLinks={getLinks} />
|
<NodeGraph dataFrames={frames} getLinks={getLinks} />
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</PanelChrome>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user