mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 15:22:06 +08:00

* Add layout buttons * Add config for node graph panel * Tests * Update test * Updates * Move grid button and cache nodes * Remove limit and add warning * Update default
331 lines
9.7 KiB
TypeScript
331 lines
9.7 KiB
TypeScript
import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import { NodeGraph } from './NodeGraph';
|
|
import { LayoutAlgorithm, ZoomMode } from './panelcfg.gen';
|
|
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
|
|
|
jest.mock('./layout', () => {
|
|
const actual = jest.requireActual('./layout');
|
|
return {
|
|
...actual,
|
|
defaultConfig: {
|
|
...actual.defaultConfig,
|
|
layoutAlgorithm: 'force',
|
|
},
|
|
};
|
|
});
|
|
|
|
jest.mock('react-use/lib/useMeasure', () => {
|
|
return {
|
|
__esModule: true,
|
|
default: () => {
|
|
return [() => {}, { width: 500, height: 200 }];
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('NodeGraph', () => {
|
|
it('shows no data message without any data', async () => {
|
|
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
|
|
|
await screen.findByText('No data');
|
|
});
|
|
|
|
it('can zoom in and out with zoom buttons', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
|
getLinks={() => []}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
const zoomIn = await screen.findByTitle(/Zoom in/);
|
|
const zoomOut = await screen.findByTitle(/Zoom out/);
|
|
|
|
expect(getScale()).toBe(1);
|
|
await userEvent.click(zoomIn);
|
|
expect(getScale()).toBe(1.5);
|
|
await userEvent.click(zoomOut);
|
|
expect(getScale()).toBe(1);
|
|
});
|
|
|
|
it('can zoom while pressing ctrl/command key with cooperative zoom mode', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
|
zoomMode={ZoomMode.Cooperative}
|
|
getLinks={() => []}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
await screen.findByLabelText('Node: service:1');
|
|
|
|
scrollView({ deltaY: -2, ctrlKey: false });
|
|
expect(getScale()).toBe(1);
|
|
|
|
scrollView({ deltaY: -2, ctrlKey: true });
|
|
expect(getScale()).toBe(1.03);
|
|
});
|
|
|
|
it('can zoom without pressing ctrl/command key with greedy zoom mode', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
|
zoomMode={ZoomMode.Greedy}
|
|
getLinks={() => []}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
await screen.findByLabelText('Node: service:1');
|
|
|
|
scrollView({ deltaY: -2, ctrlKey: true });
|
|
expect(getScale()).toBe(1.03);
|
|
|
|
scrollView({ deltaY: -2, ctrlKey: true });
|
|
expect(getScale()).toBe(1.06);
|
|
});
|
|
|
|
it('can pan the graph', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[
|
|
makeNodesDataFrame(3),
|
|
makeEdgesDataFrame([
|
|
{ source: '0', target: '1' },
|
|
{ source: '1', target: '2' },
|
|
]),
|
|
]}
|
|
getLinks={() => []}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
await screen.findByLabelText('Node: service:1');
|
|
|
|
panView({ x: 10, y: 10 });
|
|
// Though we try to pan down 10px we are rendering in straight line 3 nodes so there are bounds preventing
|
|
// as panning vertically
|
|
await waitFor(() => expect(getTranslate()).toEqual({ x: 10, y: 0 }));
|
|
});
|
|
|
|
it('renders with single node', async () => {
|
|
render(
|
|
<NodeGraph dataFrames={[makeNodesDataFrame(1)]} getLinks={() => []} layoutAlgorithm={LayoutAlgorithm.Force} />
|
|
);
|
|
const circle = await screen.findByText('', { selector: 'circle' });
|
|
await screen.findByText(/service:0/);
|
|
expect(getXY(circle)).toEqual({ x: 0, y: 0 });
|
|
});
|
|
|
|
it('shows context menu when clicking on node or edge', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
|
getLinks={(dataFrame) => {
|
|
return [
|
|
{
|
|
title: dataFrame.fields.find((f) => f.name === 'source') ? 'Edge traces' : 'Node traces',
|
|
href: '',
|
|
origin: null,
|
|
target: '_self',
|
|
},
|
|
];
|
|
}}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
// We mock this because for some reason the simulated click events don't have pageX/Y values resulting in some NaNs
|
|
// for positioning and this creates a warning message.
|
|
const origError = console.error;
|
|
console.error = jest.fn();
|
|
|
|
const node = await screen.findByTestId('node-click-rect-0');
|
|
await userEvent.click(node);
|
|
await screen.findByText(/Node traces/);
|
|
|
|
const edge = await screen.findByLabelText(/Edge from/);
|
|
await userEvent.click(edge);
|
|
await screen.findByText(/Edge traces/);
|
|
console.error = origError;
|
|
});
|
|
|
|
it('lays out 3 nodes in single line', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[
|
|
makeNodesDataFrame(3),
|
|
makeEdgesDataFrame([
|
|
{ source: '0', target: '1' },
|
|
{ source: '1', target: '2' },
|
|
]),
|
|
]}
|
|
getLinks={() => []}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
await expectNodePositionCloseTo('service:0', { x: -221, y: 0 });
|
|
await expectNodePositionCloseTo('service:1', { x: -21, y: 0 });
|
|
await expectNodePositionCloseTo('service:2', { x: 221, y: 0 });
|
|
});
|
|
|
|
it('lays out first children on one vertical line', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[
|
|
makeNodesDataFrame(3),
|
|
makeEdgesDataFrame([
|
|
{ source: '0', target: '1' },
|
|
{ source: '0', target: '2' },
|
|
]),
|
|
]}
|
|
getLinks={() => []}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
// Should basically look like <
|
|
await expectNodePositionCloseTo('service:0', { x: -100, y: 0 });
|
|
await expectNodePositionCloseTo('service:1', { x: 100, y: -100 });
|
|
await expectNodePositionCloseTo('service:2', { x: 100, y: 100 });
|
|
});
|
|
|
|
it('limits the number of nodes shown and shows a warning', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[
|
|
makeNodesDataFrame(5),
|
|
makeEdgesDataFrame([
|
|
{ source: '0', target: '1' },
|
|
{ source: '0', target: '2' },
|
|
{ source: '2', target: '3' },
|
|
{ source: '3', target: '4' },
|
|
]),
|
|
]}
|
|
getLinks={() => []}
|
|
nodeLimit={2}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
const nodes = await screen.findAllByLabelText(/Node: service:\d/);
|
|
expect(nodes.length).toBe(2);
|
|
screen.getByLabelText(/Nodes hidden warning/);
|
|
|
|
const markers = await screen.findAllByLabelText(/Hidden nodes marker: \d/);
|
|
expect(markers.length).toBe(1);
|
|
});
|
|
|
|
it('allows expanding the nodes when limiting visible nodes', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[
|
|
makeNodesDataFrame(5),
|
|
makeEdgesDataFrame([
|
|
{ source: '0', target: '1' },
|
|
{ source: '1', target: '2' },
|
|
{ source: '2', target: '3' },
|
|
{ source: '3', target: '4' },
|
|
]),
|
|
]}
|
|
getLinks={() => []}
|
|
nodeLimit={3}
|
|
layoutAlgorithm={LayoutAlgorithm.Force}
|
|
/>
|
|
);
|
|
|
|
const node = await screen.findByLabelText(/Node: service:0/);
|
|
expect(node).toBeInTheDocument();
|
|
|
|
const marker = await screen.findByLabelText(/Hidden nodes marker: 3/);
|
|
await userEvent.click(marker);
|
|
|
|
expect(screen.queryByLabelText(/Node: service:0/)).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText(/Node: service:4/)).toBeInTheDocument();
|
|
|
|
const nodes = await screen.findAllByLabelText(/Node: service:\d/);
|
|
expect(nodes.length).toBe(3);
|
|
});
|
|
|
|
it('can switch to grid layout', async () => {
|
|
render(
|
|
<NodeGraph
|
|
dataFrames={[
|
|
makeNodesDataFrame(3),
|
|
makeEdgesDataFrame([
|
|
{ source: '0', target: '1' },
|
|
{ source: '1', target: '2' },
|
|
]),
|
|
]}
|
|
getLinks={() => []}
|
|
nodeLimit={3}
|
|
/>
|
|
);
|
|
|
|
const button = await screen.findByText('Grid');
|
|
await userEvent.click(button);
|
|
|
|
await expectNodePositionCloseTo('service:0', { x: -60, y: -60 });
|
|
await expectNodePositionCloseTo('service:1', { x: 60, y: -60 });
|
|
await expectNodePositionCloseTo('service:2', { x: -60, y: 80 });
|
|
});
|
|
});
|
|
|
|
async function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) {
|
|
const nodePos = await getNodeXY(node);
|
|
expect(nodePos.x).toBeCloseTo(pos.x, -1);
|
|
expect(nodePos.y).toBeCloseTo(pos.y, -1);
|
|
}
|
|
|
|
async function getNodeXY(node: string) {
|
|
const group = await screen.findByLabelText(new RegExp(`Node: ${node}`));
|
|
const circle = getByText(group, '', { selector: 'circle' });
|
|
return getXY(circle);
|
|
}
|
|
|
|
function panView(toPos: { x: number; y: number }) {
|
|
const svg = getSvg();
|
|
fireEvent(svg, new MouseEvent('mousedown', { clientX: 0, clientY: 0 }));
|
|
fireEvent(document, new MouseEvent('mousemove', { clientX: toPos.x, clientY: toPos.y }));
|
|
fireEvent(document, new MouseEvent('mouseup'));
|
|
}
|
|
|
|
function scrollView({ deltaY, ctrlKey }: { deltaY: number; ctrlKey: boolean }) {
|
|
const svg = getSvg();
|
|
fireEvent.wheel(svg, { deltaY, ctrlKey });
|
|
}
|
|
|
|
function getSvg() {
|
|
return screen.getAllByText('', { selector: 'svg' })[0];
|
|
}
|
|
|
|
function getTransform() {
|
|
const svg = getSvg();
|
|
const group = svg.children[0] as SVGElement;
|
|
return group.style.getPropertyValue('transform');
|
|
}
|
|
|
|
function getScale() {
|
|
const scale = getTransform().match(/scale\(([\d\.]+)\)/)![1];
|
|
return parseFloat(scale);
|
|
}
|
|
|
|
function getTranslate() {
|
|
const matches = getTransform().match(/translate\((\d+)px, (\d+)px\)/);
|
|
return {
|
|
x: parseFloat(matches![1]),
|
|
y: parseFloat(matches![2]),
|
|
};
|
|
}
|
|
|
|
function getXY(e: Element) {
|
|
return {
|
|
x: parseFloat(e.attributes.getNamedItem('cx')?.value || ''),
|
|
y: parseFloat(e.attributes.getNamedItem('cy')?.value || ''),
|
|
};
|
|
}
|