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._ > _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>

View File

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

View File

@ -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&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', () => { 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();

View File

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

View File

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

View File

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