Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/rad-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"dependencies": {
"@dagrejs/dagre": "^3.1.1",
"dagre": "^0.8.5",
"html-to-image": "1.11.11",
"reactflow": "^11.11.4"
},
"publishConfig": {
Expand Down
93 changes: 90 additions & 3 deletions packages/rad-components/src/components/appgraph/AppGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import {
ReactFlow,
Edge,
Node,
useReactFlow,
useNodesState,
useEdgesState,
useStore,
ReactFlowProvider,
Controls,
ControlButton,
Panel,
getNodesBounds,
} from 'reactflow';
import Dagre, { NodeLabel } from '@dagrejs/dagre';
// Pinned to 1.11.11 in package.json: 1.11.12 and 1.11.13 leave React Flow's
// SVG edges out of the image, so the export would show only the nodes.
import { toPng } from 'html-to-image';
import { AppGraph as AppGraphData, Resource } from '../../graph';
import { ResourceNode } from '../resourcenode/index';

Expand All @@ -18,13 +25,79 @@ import { parseResourceId } from '../../resourceId';

const nodeTypes = { default: ResourceNode };

/** Blank space kept around the graph in an exported image, in pixels. */
const EXPORT_PADDING = 32;

/**
* Renders every node and edge of the graph, not just the part currently in
* view, to a PNG data URL at its natural size. The background is white so the
* image reads the same wherever it is shared.
*/
export async function exportGraphToPng(
viewport: HTMLElement,
nodes: Node[],
): Promise<string> {
const bounds = getNodesBounds(nodes);
const width = Math.ceil(bounds.width + 2 * EXPORT_PADDING);
const height = Math.ceil(bounds.height + 2 * EXPORT_PADDING);

return toPng(viewport, {
backgroundColor: '#ffffff',
width,
height,
style: {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${EXPORT_PADDING - bounds.x}px, ${
EXPORT_PADDING - bounds.y
}px) scale(1)`,
},
});
}

/** File name for an exported graph image, safe to use on any file system. */
export function graphImageFileName(graphName?: string): string {
const base = (graphName ?? '').replace(/[^\w.-]+/g, '-') || 'application';
return `${base}-graph.png`;
}

const DownloadIcon = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
</svg>
);

const LayoutFlow = (props: { graph: AppGraphData }) => {
const initial = initialNodes(props.graph);
const layoutedNodes = getLayoutedElements(initial.nodes, initial.edges, {
direction: 'TB',
});

const { fitView } = useReactFlow();
const { fitView, getNodes } = useReactFlow();
const domNode = useStore(state => state.domNode);
const [exportFailed, setExportFailed] = useState(false);
const [exporting, setExporting] = useState(false);

const downloadImage = async () => {
setExportFailed(false);
setExporting(true);
const viewport = domNode?.querySelector<HTMLElement>(
'.react-flow__viewport',
);
try {
if (!viewport) {
throw new Error('The graph is not rendered.');
}
const link = document.createElement('a');
link.href = await exportGraphToPng(viewport, getNodes());
link.download = graphImageFileName(props.graph.name);
link.click();
} catch {
setExportFailed(true);
} finally {
setExporting(false);
}
};
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedNodes.edges);

Expand Down Expand Up @@ -58,7 +131,21 @@ const LayoutFlow = (props: { graph: AppGraphData }) => {
onEdgesChange={onEdgesChange}
fitView
>
<Controls showInteractive={false} />
<Controls showInteractive={false}>
<ControlButton
onClick={downloadImage}
disabled={exporting || nodes.length === 0}
title="Download graph as PNG"
aria-label="Download graph as PNG"
>
<DownloadIcon />
</ControlButton>
</Controls>
{exportFailed && (
<Panel position="top-right" role="alert">
The graph image could not be exported.
</Panel>
)}
</ReactFlow>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import Dagre from '@dagrejs/dagre';
import AppGraph from '../AppGraph';
import { toPng } from 'html-to-image';
import AppGraph, { exportGraphToPng, graphImageFileName } from '../AppGraph';
import * as sampledata from '../../../sampledata';

// Rasterising the DOM needs a real browser; the browser path is exercised in
// the dashboard. Here the boundary is mocked to check what is asked of it.
jest.mock('html-to-image', () => ({ toPng: jest.fn() }));
const mockToPng = toPng as jest.MockedFunction<typeof toPng>;

describe('AppGraph component', () => {
// GU-17 replaces Dagre.layout on the shared module object. Without this the
// throwing implementation survives into every later test in this file.
Expand Down Expand Up @@ -33,4 +39,147 @@ describe('AppGraph component', () => {
render(<AppGraph graph={sampledata.DemoApplication} />),
).toThrow('layout failed');
});

describe('image export', () => {
let clickedLinks: HTMLAnchorElement[];

beforeEach(() => {
mockToPng.mockReset();
clickedLinks = [];
jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function record(this: HTMLAnchorElement) {
clickedLinks.push(this);
});
});

it('offers a labelled download control next to the zoom controls', () => {
render(<AppGraph graph={sampledata.DemoApplication} />);

const button = screen.getByRole('button', {
name: 'Download graph as PNG',
});
expect(button).toHaveAttribute('title', 'Download graph as PNG');
expect(button.closest('.react-flow__controls')).not.toBeNull();
});

it('downloads a PNG of the graph viewport named after the application', async () => {
mockToPng.mockResolvedValue('data:image/png;base64,AAAA');
const { container } = render(
<AppGraph graph={sampledata.DemoApplication} />,
);

fireEvent.click(
screen.getByRole('button', { name: 'Download graph as PNG' }),
);

await waitFor(() => expect(clickedLinks).toHaveLength(1));
expect(clickedLinks[0].download).toBe('demo-graph.png');
expect(clickedLinks[0].href).toBe('data:image/png;base64,AAAA');
expect(mockToPng).toHaveBeenCalledWith(
container.querySelector('.react-flow__viewport'),
expect.objectContaining({ backgroundColor: '#ffffff' }),
);
expect(screen.queryByRole('alert')).toBeNull();
});

it('reports a failed export and clears the report on a successful retry', async () => {
mockToPng.mockRejectedValueOnce(new Error('canvas tainted'));
mockToPng.mockResolvedValueOnce('data:image/png;base64,AAAA');
render(<AppGraph graph={sampledata.DemoApplication} />);
const button = screen.getByRole('button', {
name: 'Download graph as PNG',
});

fireEvent.click(button);
expect(await screen.findByRole('alert')).toHaveTextContent(
'The graph image could not be exported.',
);
expect(clickedLinks).toHaveLength(0);

fireEvent.click(button);
await waitFor(() => expect(clickedLinks).toHaveLength(1));
expect(screen.queryByRole('alert')).toBeNull();
});

it('disables the control while an export is running', async () => {
let finish: (url: string) => void = () => {};
mockToPng.mockReturnValue(
new Promise<string>(resolve => {
finish = resolve;
}),
);
render(<AppGraph graph={sampledata.DemoApplication} />);
const button = screen.getByRole('button', {
name: 'Download graph as PNG',
});

fireEvent.click(button);
await waitFor(() => expect(button).toBeDisabled());
fireEvent.click(button);

finish('data:image/png;base64,AAAA');
await waitFor(() => expect(button).toBeEnabled());
expect(mockToPng).toHaveBeenCalledTimes(1);
expect(clickedLinks).toHaveLength(1);
});

it('disables the control when the graph has no resources', () => {
render(<AppGraph graph={{ name: 'empty', resources: [] }} />);

expect(
screen.getByRole('button', { name: 'Download graph as PNG' }),
).toBeDisabled();
});

it('sizes the image to every node plus padding, wherever the view is panned', async () => {
mockToPng.mockResolvedValue('data:image/png;base64,AAAA');
const viewport = document.createElement('div');
const nodes = [
{
id: 'a',
position: { x: -100, y: 20 },
width: 150,
height: 50,
data: {},
},
{
id: 'b',
position: { x: 200, y: 300 },
width: 150,
height: 50,
data: {},
},
];

await exportGraphToPng(viewport, nodes);

// Bounds: x -100..350 (450 wide), y 20..350 (330 high); 32px padding.
expect(mockToPng).toHaveBeenCalledWith(viewport, {
backgroundColor: '#ffffff',
width: 514,
height: 394,
style: {
width: '514px',
height: '394px',
transform: 'translate(132px, 12px) scale(1)',
},
});
});

it('uses the html-to-image version that keeps edges in the image', () => {
// 1.11.12 and 1.11.13 drop React Flow's SVG edges from the exported
// image. Check an exported graph still shows its edges before changing.
expect(jest.requireActual('html-to-image/package.json').version).toBe(
'1.11.11',
);
});

it('builds a file-system-safe file name, with a fallback', () => {
expect(graphImageFileName('todo-app')).toBe('todo-app-graph.png');
expect(graphImageFileName('my app/v2')).toBe('my-app-v2-graph.png');
expect(graphImageFileName('')).toBe('application-graph.png');
expect(graphImageFileName(undefined)).toBe('application-graph.png');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ describe('ApplicationTab', () => {
});
});

it('should name the graph after the application when the response has no name', async () => {
const { name: _name, ...unnamedGraph } = graphResponse;
const mockProxy = jest
.fn()
.mockResolvedValue(
new Response(JSON.stringify(unnamedGraph), { status: 200 }),
);
const kubeApi = createMockKubernetesApi(mockProxy);

await renderInTestApp(
<TestApiProvider
apis={[
[kubernetesApiRef, kubeApi],
[radiusApiRef, mockRadiusApi],
]}
>
<ApplicationTab application="/planes/radius/local/resourceGroups/test-group/providers/Applications.Core/applications/test-app" />
</TestApiProvider>,
);

await waitFor(() => {
expect(screen.getByTestId('app-graph')).toHaveTextContent('test-app');
});
});

it('GU-16 / ER-05: KNOWN-DEFECT shows an error panel without retry when the graph request fails', async () => {
const mockProxy = jest
.fn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const useStyles = makeStyles({

export const ApplicationTab = ({ application }: { application: string }) => {
const styles = useStyles();
const applicationName = parseResourceId(application)?.name;
const kubernetesApi = useApi(kubernetesApiRef);
const radiusApi = useApi(radiusApiRef);

Expand Down Expand Up @@ -131,11 +132,13 @@ export const ApplicationTab = ({ application }: { application: string }) => {

return (
<>
<InfoCard
title={`Application Graph: ${parseResourceId(application)?.name}`}
>
<InfoCard title={`Application Graph: ${applicationName}`}>
<div className={styles.container}>
<AppGraph graph={value!} />
{/* The getGraph response has no name of its own; use the
application's so an exported image is named after it. */}
<AppGraph
graph={{ ...value, name: value.name || applicationName || '' }}
/>
</div>
</InfoCard>
</>
Expand Down
8 changes: 8 additions & 0 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.