diff --git a/packages/rad-components/package.json b/packages/rad-components/package.json index a19cdacf..027f5154 100644 --- a/packages/rad-components/package.json +++ b/packages/rad-components/package.json @@ -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": { diff --git a/packages/rad-components/src/components/appgraph/AppGraph.tsx b/packages/rad-components/src/components/appgraph/AppGraph.tsx index 7ed17dff..a521f603 100644 --- a/packages/rad-components/src/components/appgraph/AppGraph.tsx +++ b/packages/rad-components/src/components/appgraph/AppGraph.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { ReactFlow, Edge, @@ -6,10 +6,17 @@ import { 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'; @@ -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 { + 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 = () => ( + +); + 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( + '.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); @@ -58,7 +131,21 @@ const LayoutFlow = (props: { graph: AppGraphData }) => { onEdgesChange={onEdgesChange} fitView > - + + + + + + {exportFailed && ( + + The graph image could not be exported. + + )} ); }; diff --git a/packages/rad-components/src/components/appgraph/__test__/AppGraph.test.tsx b/packages/rad-components/src/components/appgraph/__test__/AppGraph.test.tsx index 2a231809..7a79a769 100644 --- a/packages/rad-components/src/components/appgraph/__test__/AppGraph.test.tsx +++ b/packages/rad-components/src/components/appgraph/__test__/AppGraph.test.tsx @@ -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; + 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. @@ -33,4 +39,147 @@ describe('AppGraph component', () => { render(), ).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(); + + 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( + , + ); + + 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(); + 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(resolve => { + finish = resolve; + }), + ); + render(); + 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(); + + 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'); + }); + }); }); diff --git a/plugins/plugin-radius/src/components/resources/ApplicationTab.test.tsx b/plugins/plugin-radius/src/components/resources/ApplicationTab.test.tsx index 41706c1f..6bd1fe7b 100644 --- a/plugins/plugin-radius/src/components/resources/ApplicationTab.test.tsx +++ b/plugins/plugin-radius/src/components/resources/ApplicationTab.test.tsx @@ -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( + + + , + ); + + 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() diff --git a/plugins/plugin-radius/src/components/resources/ApplicationTab.tsx b/plugins/plugin-radius/src/components/resources/ApplicationTab.tsx index c63984eb..72c199b5 100644 --- a/plugins/plugin-radius/src/components/resources/ApplicationTab.tsx +++ b/plugins/plugin-radius/src/components/resources/ApplicationTab.tsx @@ -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); @@ -131,11 +132,13 @@ export const ApplicationTab = ({ application }: { application: string }) => { return ( <> - +
- + {/* The getGraph response has no name of its own; use the + application's so an exported image is named after it. */} +
diff --git a/yarn.lock b/yarn.lock index cd753f59..f8cc166a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9513,6 +9513,7 @@ __metadata: "@types/react": "npm:^18" babel-jest: "npm:^30.5.2" dagre: "npm:^0.8.5" + html-to-image: "npm:1.11.11" identity-obj-proxy: "npm:^3.0.0" jest: "npm:^30.5.2" jsdom: "npm:^29.1.1" @@ -20102,6 +20103,13 @@ __metadata: languageName: node linkType: hard +"html-to-image@npm:1.11.11": + version: 1.11.11 + resolution: "html-to-image@npm:1.11.11" + checksum: 10c0/0b6349221ad253dfca01d165c589d44341e942faf0273aab28c8b7d86ff2922d3e8e6390f57bf5ddaf6bac9a3b590a8cdaa77d52a363354796dd0e0e05eb35d2 + languageName: node + linkType: hard + "html-void-elements@npm:^2.0.0": version: 2.0.1 resolution: "html-void-elements@npm:2.0.1"