feat: allow opening dependency graph in fullscreen

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-06-19 13:37:46 +03:00
parent b1bd24a0b2
commit 1ad3d94a22
8 changed files with 181 additions and 77 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Dependency graph can now be opened in full screen mode
+2
View File
@@ -80,6 +80,7 @@
"pluralize": "^8.0.0",
"qs": "^6.9.4",
"rc-progress": "3.5.1",
"react-full-screen": "^1.1.1",
"react-helmet": "6.1.0",
"react-hook-form": "^7.12.2",
"react-idle-timer": "5.7.2",
@@ -106,6 +107,7 @@
"@types/d3-selection": "^3.0.1",
"@types/d3-shape": "^3.0.1",
"@types/d3-zoom": "^3.0.1",
"@types/fscreen": "^1",
"@types/google-protobuf": "^3.7.2",
"@types/react": "^18.0.0",
"@types/react-helmet": "^6.1.0",
@@ -61,6 +61,7 @@ export const coreComponentsTranslationRef: TranslationRef<
readonly 'alertDisplay.message_other': '({{ count }} newer messages)';
readonly 'autoLogout.stillTherePrompt.title': 'Logging out due to inactivity';
readonly 'autoLogout.stillTherePrompt.buttonText': "Yes! Don't log me out";
readonly 'dependencyGraph.fullscreenTooltip': 'Toggle fullscreen';
readonly 'proxiedSignInPage.title': 'You do not appear to be signed in. Please try reloading the browser page.';
}
>;
+1
View File
@@ -257,6 +257,7 @@ export interface DependencyGraphProps<NodeData, EdgeData>
extends SVGProps<SVGSVGElement> {
acyclicer?: 'greedy';
align?: DependencyGraphTypes.Alignment;
allowFullscreen?: boolean;
curve?: 'curveStepBefore' | 'curveMonotoneX';
defs?: JSX.Element | JSX.Element[];
direction?: DependencyGraphTypes.Direction;
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { render } from '@testing-library/react';
import { DependencyGraph } from './DependencyGraph';
import { DependencyGraphTypes as Types } from './types';
import { EDGE_TEST_ID, LABEL_TEST_ID, NODE_TEST_ID } from './constants';
import { renderInTestApp } from '@backstage/test-utils';
describe('<DependencyGraph />', () => {
beforeAll(() => {
@@ -36,9 +36,8 @@ describe('<DependencyGraph />', () => {
const CUSTOM_TEST_ID = 'custom-test-id';
it('renders each node and edge supplied', async () => {
const { getByText, queryAllByTestId, findAllByTestId } = render(
<DependencyGraph nodes={nodes} edges={edges} />,
);
const { getByText, queryAllByTestId, findAllByTestId } =
await renderInTestApp(<DependencyGraph nodes={nodes} edges={edges} />);
const renderedNodes = await findAllByTestId(NODE_TEST_ID);
expect(renderedNodes).toHaveLength(3);
expect(getByText(nodes[0].id)).toBeInTheDocument();
@@ -49,9 +48,10 @@ describe('<DependencyGraph />', () => {
});
it('update render if already referenced nodes are added later', async () => {
const { getByText, queryAllByTestId, findAllByTestId, rerender } = render(
<DependencyGraph nodes={nodes.slice(0, 2)} edges={edges} />,
);
const { getByText, queryAllByTestId, findAllByTestId, rerender } =
await renderInTestApp(
<DependencyGraph nodes={nodes.slice(0, 2)} edges={edges} />,
);
let renderedNodes = await findAllByTestId(NODE_TEST_ID);
expect(renderedNodes).toHaveLength(2);
@@ -75,9 +75,10 @@ describe('<DependencyGraph />', () => {
{ ...edges[0], label: 'first' },
{ ...edges[1], label: 'second' },
];
const { getByText, getAllByTestId, findAllByTestId } = render(
<DependencyGraph nodes={nodes} edges={labeledEdges} />,
);
const { getByText, getAllByTestId, findAllByTestId } =
await renderInTestApp(
<DependencyGraph nodes={nodes} edges={labeledEdges} />,
);
const renderedEdges = await findAllByTestId(EDGE_TEST_ID);
expect(renderedEdges).toHaveLength(2);
expect(getAllByTestId(LABEL_TEST_ID)).toHaveLength(2);
@@ -94,7 +95,7 @@ describe('<DependencyGraph />', () => {
<circle data-testid={CUSTOM_TEST_ID} r={100} />
</g>
);
const { getByText, findByTestId, container } = render(
const { getByText, findByTestId, container } = await renderInTestApp(
<DependencyGraph nodes={singleNode} edges={[]} renderNode={renderNode} />,
);
const node = await findByTestId(CUSTOM_TEST_ID);
@@ -112,7 +113,7 @@ describe('<DependencyGraph />', () => {
<circle data-testid={CUSTOM_TEST_ID} r={100} />
</g>
);
const { getByText, findByTestId, container } = render(
const { getByText, findByTestId, container } = await renderInTestApp(
<DependencyGraph
nodes={nodes}
edges={labeledEdge}
@@ -16,11 +16,11 @@
import {
SVGProps,
useState,
useRef,
useMemo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as d3Zoom from 'd3-zoom';
import * as d3Selection from 'd3-selection';
@@ -31,6 +31,25 @@ import { DependencyGraphTypes as Types } from './types';
import { Node } from './Node';
import { Edge, GraphEdge } from './Edge';
import { ARROW_MARKER_ID } from './constants';
import IconButton from '@material-ui/core/IconButton';
import FullscreenIcon from '@material-ui/icons/Fullscreen';
import FullscreenExitIcon from '@material-ui/icons/FullscreenExit';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import { makeStyles, Theme } from '@material-ui/core/styles';
import Tooltip from '@material-ui/core/Tooltip';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { coreComponentsTranslationRef } from '../../translation';
const useStyles = makeStyles((theme: Theme) => ({
root: {
overflow: 'hidden',
minHeight: '100%',
minWidth: '100%',
},
fullscreen: {
backgroundColor: theme.palette.background.paper,
},
}));
/**
* Properties of {@link DependencyGraph}
@@ -181,9 +200,18 @@ export interface DependencyGraphProps<NodeData, EdgeData>
* Default: 'grow'
*/
fit?: 'grow' | 'contain';
/**
* Controls if user can toggle fullscreen mode
*
* @remarks
*
* Default: true
*/
allowFullscreen?: boolean;
}
const WORKSPACE_ID = 'workspace';
const DEPENDENCY_GRAPH_SVG = 'dependency-graph';
/**
* Graph component used to visualize relations between entities
@@ -216,11 +244,15 @@ export function DependencyGraph<NodeData, EdgeData>(
curve = 'curveMonotoneX',
showArrowHeads = false,
fit = 'grow',
allowFullscreen = true,
...svgProps
} = props;
const theme = useTheme();
const [containerWidth, setContainerWidth] = useState<number>(100);
const [containerHeight, setContainerHeight] = useState<number>(100);
const fullScreenHandle = useFullScreenHandle();
const styles = useStyles();
const { t } = useTranslationRef(coreComponentsTranslationRef);
const graph = useRef<dagre.graphlib.Graph<Types.DependencyNode<NodeData>>>(
new dagre.graphlib.Graph(),
@@ -242,11 +274,17 @@ export function DependencyGraph<NodeData, EdgeData>(
const containerRef = useMemo(
() =>
debounce((node: SVGSVGElement) => {
if (!node) {
debounce((root: HTMLDivElement) => {
if (!root) {
return;
}
// Set up zooming + panning
const node: SVGSVGElement = root.querySelector(
`svg#${DEPENDENCY_GRAPH_SVG}`,
) as SVGSVGElement;
if (!node) {
return;
}
const container = d3Selection.select<SVGSVGElement, null>(node);
const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID));
@@ -282,7 +320,7 @@ export function DependencyGraph<NodeData, EdgeData>(
}
const { width: newContainerWidth, height: newContainerHeight } =
node.getBoundingClientRect();
root.getBoundingClientRect();
if (containerWidth !== newContainerWidth) {
setContainerWidth(newContainerWidth);
}
@@ -406,68 +444,94 @@ export function DependencyGraph<NodeData, EdgeData>(
}
return (
<svg
ref={containerRef}
{...svgProps}
width="100%"
height={scalableHeight}
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
>
<defs>
<marker
id={ARROW_MARKER_ID}
viewBox="0 0 24 24"
markerWidth="14"
markerHeight="14"
refX="16"
refY="12"
orient="auto"
markerUnits="strokeWidth"
>
<path
fill={theme.palette.textSubtle}
d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"
/>
</marker>
{defs}
</defs>
<g id={WORKSPACE_ID}>
<div ref={containerRef} className={styles.root}>
<FullScreen
handle={fullScreenHandle}
className={fullScreenHandle.active ? styles.fullscreen : styles.root}
>
{allowFullscreen && (
<Tooltip title={t('dependencyGraph.fullscreenTooltip')}>
<IconButton
style={{ float: 'right' }}
onClick={
fullScreenHandle.active
? fullScreenHandle.exit
: fullScreenHandle.enter
}
>
{fullScreenHandle.active ? (
<FullscreenExitIcon />
) : (
<FullscreenIcon />
)}
</IconButton>
</Tooltip>
)}
<svg
width={graphWidth}
height={graphHeight}
y={maxHeight / 2 - graphHeight / 2}
x={maxWidth / 2 - graphWidth / 2}
viewBox={`0 0 ${graphWidth} ${graphHeight}`}
{...svgProps}
width="100%"
height={scalableHeight}
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
id={DEPENDENCY_GRAPH_SVG}
>
{graphEdges.map(e => {
const edge = graph.current.edge(e) as GraphEdge<EdgeData>;
if (!edge) return null;
return (
<Edge
key={`${e.v}-${e.w}`}
id={e}
setEdge={setEdge}
render={renderLabel}
edge={edge}
curve={curve}
showArrowHeads={showArrowHeads}
<defs>
<marker
id={ARROW_MARKER_ID}
viewBox="0 0 24 24"
markerWidth="14"
markerHeight="14"
refX="16"
refY="12"
orient="auto"
markerUnits="strokeWidth"
>
<path
fill={theme.palette.textSubtle}
d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"
/>
);
})}
{graphNodes.map((id: string) => {
const node = graph.current.node(id);
if (!node) return null;
return (
<Node
key={id}
setNode={setNode}
render={renderNode}
node={node}
/>
);
})}
</marker>
{defs}
</defs>
<g id={WORKSPACE_ID}>
<svg
width={graphWidth}
height={graphHeight}
y={maxHeight / 2 - graphHeight / 2}
x={maxWidth / 2 - graphWidth / 2}
viewBox={`0 0 ${graphWidth} ${graphHeight}`}
>
{graphEdges.map(e => {
const edge = graph.current.edge(e) as GraphEdge<EdgeData>;
if (!edge) return null;
return (
<Edge
key={`${e.v}-${e.w}`}
id={e}
setEdge={setEdge}
render={renderLabel}
edge={edge}
curve={curve}
showArrowHeads={showArrowHeads}
/>
);
})}
{graphNodes.map((id: string) => {
const node = graph.current.node(id);
if (!node) return null;
return (
<Node
key={id}
setNode={setNode}
render={renderNode}
node={node}
/>
);
})}
</svg>
</g>
</svg>
</g>
</svg>
</FullScreen>
</div>
);
}
@@ -120,6 +120,9 @@ export const coreComponentsTranslationRef = createTranslationRef({
buttonText: "Yes! Don't log me out",
},
},
dependencyGraph: {
fullscreenTooltip: 'Toggle fullscreen',
},
proxiedSignInPage: {
title:
'You do not appear to be signed in. Please try reloading the browser page.',
+27
View File
@@ -3182,6 +3182,7 @@ __metadata:
"@types/d3-selection": "npm:^3.0.1"
"@types/d3-shape": "npm:^3.0.1"
"@types/d3-zoom": "npm:^3.0.1"
"@types/fscreen": "npm:^1"
"@types/google-protobuf": "npm:^3.7.2"
"@types/react": "npm:^18.0.0"
"@types/react-helmet": "npm:^6.1.0"
@@ -3207,6 +3208,7 @@ __metadata:
rc-progress: "npm:3.5.1"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-full-screen: "npm:^1.1.1"
react-helmet: "npm:6.1.0"
react-hook-form: "npm:^7.12.2"
react-idle-timer: "npm:5.7.2"
@@ -20266,6 +20268,13 @@ __metadata:
languageName: node
linkType: hard
"@types/fscreen@npm:^1":
version: 1.0.4
resolution: "@types/fscreen@npm:1.0.4"
checksum: 10/78459a457ce7a6b7d72a5f17fdb54bbeb93c58ab77fd2858aac610fed2435bc4be9e5d2fb9883b6669b7f3a1204115cc2be59a027ab937ee8b5186225d2ea53d
languageName: node
linkType: hard
"@types/git-url-parse@npm:^9.0.0":
version: 9.0.3
resolution: "@types/git-url-parse@npm:9.0.3"
@@ -31293,6 +31302,13 @@ __metadata:
languageName: node
linkType: hard
"fscreen@npm:^1.0.2":
version: 1.2.0
resolution: "fscreen@npm:1.2.0"
checksum: 10/ac50f9ac52a157b8fe6aaecdf9efa7c1cfa90b42a76c3bc6b85372fab05c5a9cd72c1b7f4c2e273eba1a0e630e381fd72ae135fcc57acd05a0943d5d0c21b451
languageName: node
linkType: hard
"fsevents@npm:2.3.2":
version: 2.3.2
resolution: "fsevents@npm:2.3.2"
@@ -42823,6 +42839,17 @@ __metadata:
languageName: node
linkType: hard
"react-full-screen@npm:^1.1.1":
version: 1.1.1
resolution: "react-full-screen@npm:1.1.1"
dependencies:
fscreen: "npm:^1.0.2"
peerDependencies:
react: ">= 16.8.0"
checksum: 10/70ad927b9d6c485ac46b5bb4b1639ef9a860da28290b3a1c419c42b9c427d78b80e8dba403eb6451458af56838012c81d5e12ef05097395f154defc32fe06c34
languageName: node
linkType: hard
"react-grid-layout@npm:1.3.4":
version: 1.3.4
resolution: "react-grid-layout@npm:1.3.4"