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:
Haris Rozajac 2023-08-24 15:30:25 +02:00 committed by GitHub
parent d293b08e52
commit 19ae937aa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 151 additions and 46 deletions

View File

@ -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._
```tsx
<PanelChrome title="My awesome panel title" width={400} height={200} collapsible={true}>
{(innerwidth, innerheight) => {
return (
<div
style={{
width: innerwidth,
height: innerheight,
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Content
</div>
);
}}
</PanelChrome>
function Container() {
const [isCollapsed, setCollapsed] = useState(true);
return (
<PanelChrome
title="My awesome panel title"
width={400}
height={200}
collapsible={true}
collapsed={isCollapsed}
onToggleCollapse={(isCollapsed) => setCollapsed(isCollapsed)}
>
{(innerwidth, innerheight) => {
return (
<div
style={{
width: innerwidth,
height: innerheight,
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Content
</div>
);
}}
</PanelChrome>
);
}
```
<Preview>

View File

@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import { merge } from 'lodash';
import React, { CSSProperties, useState, ReactNode } from 'react';
import { useInterval } from 'react-use';
import { useInterval, useToggle } from 'react-use';
import { LoadingState } from '@grafana/data';
import { Button, Icon, PanelChrome, PanelChromeProps, RadioButtonGroup } from '@grafana/ui';
@ -14,6 +14,9 @@ import { Menu } from '../Menu/Menu';
import mdx from './PanelChrome.mdx';
const PANEL_WIDTH = 400;
const PANEL_HEIGHT = 150;
const meta: Meta<typeof PanelChrome> = {
title: 'Visualizations/PanelChrome',
component: PanelChrome,
@ -39,8 +42,8 @@ function getContentStyle(): CSSProperties {
function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
const props: PanelChromeProps = {
width: 400,
height: 150,
width: PANEL_WIDTH,
height: PANEL_HEIGHT,
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 = (
<Menu>
<Menu.Item label="View" icon="eye" />
@ -215,7 +245,7 @@ export const Examples = () => {
/>,
],
})}
{renderPanel('Collapsible panel', {
{renderCollapsiblePanel('Collapsible panel', {
title: 'Default title',
collapsible: true,
})}

View File

@ -1,5 +1,6 @@
import { screen, render, fireEvent } from '@testing-library/react';
import React from 'react';
import { useToggle } from 'react-use';
import { LoadingState } from '@grafana/data';
@ -18,6 +19,27 @@ const setup = (propOverrides?: Partial<PanelChromeProps>) => {
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&apos;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', () => {
setup();
@ -125,8 +147,25 @@ it('renders streaming indicator in the panel header if loadingState is streaming
expect(screen.getByTestId('panel-streaming')).toBeInTheDocument();
});
it('collapes the panel when user clicks on the chevron or the title', () => {
setup({ collapsible: true, title: 'Default title' });
it('collapses the controlled panel when user clicks on the chevron or the 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();

View File

@ -71,12 +71,19 @@ interface AutoSize extends BaseProps {
interface Collapsible {
collapsible: boolean;
collapsed?: boolean;
/**
* callback when collapsing or expanding the panel
*/
onToggleCollapse?: (collapsed: boolean) => void;
hoverHeader?: never;
hoverHeaderOffset?: never;
}
interface HoverHeader {
collapsible?: never;
collapsed?: never;
onToggleCollapse?: never;
hoverHeader?: boolean;
hoverHeaderOffset?: number;
}
@ -111,14 +118,21 @@ export function PanelChrome({
onCancelQuery,
onOpenMenu,
collapsible = false,
collapsed,
onToggleCollapse,
}: PanelChromeProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const panelContentId = useId();
const hasHeader = !hoverHeader;
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
const showOnHoverClass = 'show-on-hover';
@ -129,7 +143,7 @@ export function PanelChrome({
padding,
theme,
headerHeight,
isOpen,
collapsed,
height,
width
);
@ -139,7 +153,7 @@ export function PanelChrome({
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>();
/** Old property name now maps to actions */
@ -154,12 +168,17 @@ export function PanelChrome({
<button
type="button"
className={styles.clearButtonStyles}
onClick={toggleOpen}
aria-expanded={isOpen}
aria-controls={isOpen ? panelContentId : undefined}
onClick={() => {
toggleOpen();
if (onToggleCollapse) {
onToggleCollapse(!collapsed);
}
}}
aria-expanded={!collapsed}
aria-controls={!collapsed ? panelContentId : undefined}
>
<Icon
name={isOpen ? 'angle-down' : 'angle-right'}
name={!collapsed ? 'angle-down' : 'angle-right'}
aria-hidden={!!title}
aria-label={!title ? 'toggle collapse panel' : undefined}
/>
@ -265,7 +284,7 @@ export function PanelChrome({
</div>
)}
{isOpen && (
{!collapsed && (
<div
id={panelContentId}
className={cx(styles.content, height === undefined && styles.containNone)}
@ -295,7 +314,7 @@ const getContentStyle = (
padding: string,
theme: GrafanaTheme2,
headerHeight: number,
isOpen: boolean,
collapsed: boolean,
height?: number,
width?: number
) => {
@ -314,7 +333,7 @@ const getContentStyle = (
innerHeight = height - headerHeight - panelPadding - panelBorder;
}
if (!isOpen) {
if (collapsed) {
innerHeight = headerHeight;
}

View File

@ -18,8 +18,8 @@ describe('NodeGraphContainer', () => {
/>
);
// Make sure we only show header in the collapsible
expect(container.firstChild?.childNodes.length).toBe(1);
// Make sure we only show header and loading bar container from PanelChrome in the collapsible
expect(container.firstChild?.childNodes.length).toBe(2);
});
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();
await screen.findByLabelText(/Node: tempo-querier/);
});

View File

@ -5,7 +5,7 @@ import { useToggle, useWindowSize } from 'react-use';
import { applyFieldOverrides, DataFrame, GrafanaTheme2, SplitOpen } from '@grafana/data';
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 { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames';
@ -15,6 +15,8 @@ import { useLinks } from '../utils/links';
const getStyles = (theme: GrafanaTheme2) => ({
warningText: css`
label: warningText;
display: flex;
align-items: center;
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
`,
@ -53,9 +55,10 @@ export function UnconnectedNodeGraphContainer(props: Props) {
});
const { nodes } = useCategorizeFrames(frames);
const [open, toggleOpen] = useToggle(false);
const [collapsed, toggleCollapsed] = useToggle(true);
const toggled = () => {
toggleOpen();
toggleCollapsed();
reportInteraction('grafana_traces_node_graph_panel_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
@ -81,12 +84,13 @@ export function UnconnectedNodeGraphContainer(props: Props) {
) : null;
return (
<Collapse
label={<span>Node graph{countWarning} </span>}
collapsible={withTraceView}
<PanelChrome
title={`Node graph`}
titleItems={countWarning}
// We allow collapsing this only when it is shown together with trace view.
isOpen={withTraceView ? open : true}
onToggle={withTraceView ? () => toggled() : undefined}
collapsible={!!withTraceView}
collapsed={withTraceView ? collapsed : false}
onToggleCollapse={withTraceView ? toggled : undefined}
>
<div
ref={containerRef}
@ -101,7 +105,7 @@ export function UnconnectedNodeGraphContainer(props: Props) {
>
<NodeGraph dataFrames={frames} getLinks={getLinks} />
</div>
</Collapse>
</PanelChrome>
);
}