Skip to content

Commit 0822820

Browse files
authored
feat(data-modeling): export diagram to png COMPASS-9449 (#7055)
* extract diagram editor toolbar: * add export modal * json export * tests * close modal * tests * ensure test run * fix toast * fix electron test * fix link * ensure its thrown * asset number of selected collections * fix modal styles * return null for tests * remove comment * export image to png * move container to the export png * add ocr e2e test * abortable export * use package methods * clean up * update diagramming * npm install * npm check * fix test * update npm * fix spaces issue * fix popup for firefox * skip on win and lowercase test assertions * cr comments * npm install
1 parent 79a6f24 commit 0822820

File tree

10 files changed

+744
-283
lines changed

10 files changed

+744
-283
lines changed

package-lock.json

Lines changed: 421 additions & 221 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-data-modeling/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,18 @@
6161
"@mongodb-js/compass-logging": "^1.7.4",
6262
"@mongodb-js/compass-telemetry": "^1.10.2",
6363
"@mongodb-js/compass-user-data": "^0.7.4",
64+
"@mongodb-js/compass-utils": "^0.9.2",
6465
"@mongodb-js/compass-workspaces": "^0.44.0",
65-
"@mongodb-js/diagramming": "^1.1.0",
66+
"@mongodb-js/diagramming": "^1.2.0",
6667
"bson": "^6.10.3",
6768
"compass-preferences-model": "^2.43.0",
69+
"html-to-image": "1.11.11",
6870
"lodash": "^4.17.21",
6971
"mongodb": "^6.14.1",
7072
"mongodb-ns": "^2.4.2",
7173
"mongodb-schema": "^12.6.2",
7274
"react": "^17.0.2",
75+
"react-dom": "^17.0.2",
7376
"react-redux": "^8.1.3",
7477
"redux": "^4.2.1",
7578
"redux-thunk": "^2.4.2"
@@ -90,7 +93,6 @@
9093
"depcheck": "^1.4.1",
9194
"mocha": "^10.2.0",
9295
"nyc": "^15.1.0",
93-
"react-dom": "^17.0.2",
9496
"sinon": "^17.0.1",
9597
"typescript": "^5.0.4",
9698
"xvfb-maybe": "^0.2.1"

packages/compass-data-modeling/src/components/export-diagram-modal.tsx

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useState } from 'react';
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
22
import {
33
Button,
44
css,
@@ -12,6 +12,8 @@ import {
1212
Radio,
1313
RadioGroup,
1414
spacing,
15+
SpinLoader,
16+
openToast,
1517
} from '@mongodb-js/compass-components';
1618
import {
1719
closeExportModal,
@@ -21,7 +23,9 @@ import {
2123
import { connect } from 'react-redux';
2224
import type { DataModelingState } from '../store/reducer';
2325
import type { StaticModel } from '../services/data-model-storage';
24-
import { exportToJson } from '../services/export-diagram';
26+
import { exportToJson, exportToPng } from '../services/export-diagram';
27+
import { useDiagram } from '@mongodb-js/diagramming';
28+
import { isCancelError } from '@mongodb-js/compass-utils';
2529

2630
const nbsp = '\u00a0';
2731

@@ -59,20 +63,68 @@ const ExportDiagramModal = ({
5963
model,
6064
onCloseClick,
6165
}: ExportDiagramModalProps) => {
62-
const [exportFormat, setExportFormat] = useState<'json' | null>(null);
63-
64-
const onExport = useCallback(() => {
65-
if (!exportFormat || !model) {
66-
return;
66+
const [exportFormat, setExportFormat] = useState<'png' | 'json' | null>(null);
67+
const diagram = useDiagram();
68+
const [isExporting, setIsExporting] = useState(false);
69+
const abortControllerRef = useRef<AbortController | null>(null);
70+
useEffect(() => {
71+
const cleanup = () => {
72+
if (abortControllerRef.current) {
73+
abortControllerRef.current.abort();
74+
abortControllerRef.current = null;
75+
}
76+
};
77+
const abortController = new AbortController();
78+
if (isModalOpen) {
79+
abortControllerRef.current = abortController;
80+
} else {
81+
cleanup();
6782
}
68-
exportToJson(diagramLabel, model);
83+
return cleanup;
84+
}, [isModalOpen]);
85+
86+
const onClose = useCallback(() => {
87+
setIsExporting(false);
88+
abortControllerRef.current?.abort();
89+
abortControllerRef.current = null;
6990
onCloseClick();
70-
}, [exportFormat, onCloseClick, model, diagramLabel]);
91+
}, [onCloseClick]);
92+
93+
const onExport = useCallback(async () => {
94+
try {
95+
if (!exportFormat || !model) {
96+
return;
97+
}
98+
setIsExporting(true);
99+
if (exportFormat === 'json') {
100+
exportToJson(diagramLabel, model);
101+
} else if (exportFormat === 'png') {
102+
await exportToPng(
103+
diagramLabel,
104+
diagram,
105+
abortControllerRef.current?.signal
106+
);
107+
}
108+
} catch (error) {
109+
if (isCancelError(error)) {
110+
return;
111+
}
112+
openToast('export-diagram-error', {
113+
variant: 'warning',
114+
title: 'Export failed',
115+
description: `An error occurred while exporting the diagram: ${
116+
(error as Error).message
117+
}`,
118+
});
119+
} finally {
120+
onClose();
121+
}
122+
}, [exportFormat, onClose, model, diagram, diagramLabel]);
71123

72124
return (
73125
<Modal
74126
open={isModalOpen}
75-
setOpen={onCloseClick}
127+
setOpen={onClose}
76128
data-testid="export-diagram-modal"
77129
>
78130
<ModalHeader
@@ -95,6 +147,17 @@ const ExportDiagramModal = ({
95147
<div className={contentContainerStyles}>
96148
<Label htmlFor="">Select file format:</Label>
97149
<RadioGroup className={contentContainerStyles} value={exportFormat}>
150+
<div className={radioItemStyles}>
151+
<Icon glyph="Diagram2" />
152+
<Radio
153+
checked={exportFormat === 'png'}
154+
value="png"
155+
aria-label="PNG"
156+
onClick={() => setExportFormat('png')}
157+
>
158+
PNG
159+
</Radio>
160+
</div>
98161
<div className={radioItemStyles}>
99162
<Icon glyph="CurlyBraces" />
100163
<Radio
@@ -114,14 +177,13 @@ const ExportDiagramModal = ({
114177
variant="primary"
115178
onClick={() => void onExport()}
116179
data-testid="export-button"
180+
disabled={!exportFormat || !model}
181+
loadingIndicator={<SpinLoader />}
182+
isLoading={isExporting}
117183
>
118184
Export
119185
</Button>
120-
<Button
121-
variant="default"
122-
onClick={onCloseClick}
123-
data-testid="cancel-button"
124-
>
186+
<Button variant="default" onClick={onClose} data-testid="cancel-button">
125187
Cancel
126188
</Button>
127189
</ModalFooter>

packages/compass-data-modeling/src/services/export-diagram.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import React from 'react';
2+
import {
3+
getNodesBounds,
4+
getViewportForBounds,
5+
DiagramProvider,
6+
Diagram,
7+
} from '@mongodb-js/diagramming';
8+
import type { DiagramInstance } from '@mongodb-js/diagramming';
9+
import type { StaticModel } from './data-model-storage';
10+
import ReactDOM from 'react-dom';
11+
import { toPng } from 'html-to-image';
12+
import { rafraf, spacing } from '@mongodb-js/compass-components';
13+
import { raceWithAbort } from '@mongodb-js/compass-utils';
14+
15+
function moveSvgDefsToViewportElement(
16+
container: Element,
17+
targetElement: Element
18+
) {
19+
const markerDef = container.querySelector('svg defs');
20+
if (!markerDef) {
21+
return;
22+
}
23+
const diagramSvgElements = targetElement.querySelectorAll('svg');
24+
diagramSvgElements.forEach((svg) => {
25+
const pathsWithMarkers = svg.querySelectorAll(
26+
'path[marker-end], path[marker-start]'
27+
);
28+
if (pathsWithMarkers.length !== 0) {
29+
const clonedDef = markerDef.cloneNode(true);
30+
svg.insertBefore(clonedDef, svg.firstChild);
31+
}
32+
});
33+
markerDef.remove();
34+
}
35+
36+
export async function exportToPng(
37+
fileName: string,
38+
diagram: DiagramInstance,
39+
signal?: AbortSignal
40+
) {
41+
const dataUri = await raceWithAbort(
42+
getExportPngDataUri(diagram),
43+
signal ?? new AbortController().signal
44+
);
45+
downloadFile(dataUri, fileName);
46+
}
47+
48+
export function getExportPngDataUri(diagram: DiagramInstance): Promise<string> {
49+
return new Promise<string>((resolve, _reject) => {
50+
const bounds = getNodesBounds(diagram.getNodes());
51+
52+
const container = document.createElement('div');
53+
container.setAttribute('data-testid', 'export-diagram-container');
54+
// Push it out of the viewport
55+
container.style.position = 'fixed';
56+
container.style.top = '100vh';
57+
container.style.left = '100vw';
58+
container.style.width = `${bounds.width}px`;
59+
container.style.height = `${bounds.height}px`;
60+
document.body.appendChild(container);
61+
62+
const edges = diagram.getEdges();
63+
const nodes = diagram.getNodes().map((node) => ({
64+
...node,
65+
selected: false, // Dont show selected state (blue border)
66+
}));
67+
68+
const reject = (error: Error) => {
69+
document.body.removeChild(container);
70+
_reject(error);
71+
};
72+
73+
ReactDOM.render(
74+
<DiagramProvider>
75+
<Diagram
76+
edges={edges}
77+
nodes={nodes}
78+
onlyRenderVisibleElements={false}
79+
/>
80+
</DiagramProvider>,
81+
container,
82+
() => {
83+
rafraf(() => {
84+
// For export we are selecting react-flow__viewport element,
85+
// which contains the export canvas. It excludes diagram
86+
// title, minmap, and other UI elements. However, it also
87+
// excludes the svg defs that are currently outside of this element.
88+
// So, when exporting, we need to include those defs as well so that
89+
// edge markers are exported correctly.
90+
const viewportElement = container.querySelector(
91+
'.react-flow__viewport'
92+
);
93+
if (!viewportElement) {
94+
return reject(new Error('Diagram element not found'));
95+
}
96+
97+
const transform = getViewportForBounds(
98+
bounds,
99+
bounds.width,
100+
bounds.height,
101+
0.5, // Minimum zoom
102+
2, // Maximum zoom
103+
`${spacing[400]}px` // 16px padding
104+
);
105+
106+
// Moving svg defs to the viewport element
107+
moveSvgDefsToViewportElement(container, viewportElement);
108+
rafraf(() => {
109+
toPng(viewportElement as HTMLElement, {
110+
backgroundColor: '#fff',
111+
pixelRatio: 2,
112+
width: bounds.width,
113+
height: bounds.height,
114+
style: {
115+
width: `${bounds.width}px`,
116+
height: `${bounds.height}px`,
117+
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
118+
},
119+
})
120+
.then(resolve)
121+
.catch(reject)
122+
.finally(() => {
123+
document.body.removeChild(container);
124+
});
125+
});
126+
});
127+
}
128+
);
129+
});
130+
}
131+
132+
export function exportToJson(fileName: string, model: StaticModel) {
133+
const json = getExportJsonFromModel(model);
134+
const blob = new Blob([JSON.stringify(json, null, 2)], {
135+
type: 'application/json',
136+
});
137+
const url = window.URL.createObjectURL(blob);
138+
downloadFile(url, fileName, () => {
139+
window.URL.revokeObjectURL(url);
140+
});
141+
}
142+
143+
export function getExportJsonFromModel({
144+
collections,
145+
relationships,
146+
}: StaticModel) {
147+
return {
148+
collections: Object.fromEntries(
149+
collections.map((collection) => {
150+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
151+
const { ns, jsonSchema, ...ignoredProps } = collection;
152+
return [ns, { ns, jsonSchema }];
153+
})
154+
),
155+
relationships,
156+
};
157+
}
158+
159+
function downloadFile(uri: string, fileName: string, cleanup?: () => void) {
160+
const link = document.createElement('a');
161+
link.download = fileName;
162+
link.href = uri;
163+
link.click();
164+
setTimeout(() => {
165+
link.remove();
166+
cleanup?.();
167+
}, 0);
168+
}

packages/compass-e2e-tests/helpers/commands/hide-visible-toasts.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ export async function hideAllVisibleToasts(
2727

2828
const toasts = browser.$(Selectors.LGToastContainer).$$('div');
2929
for (const _toast of toasts) {
30-
// if they all went away at some point, just stop
31-
if (!(await isToastContainerVisible(browser))) {
30+
// if they all went away at some point
31+
// or if that toast is not visible anymore just stop
32+
if (!(await isToastContainerVisible(browser)) || !_toast) {
3233
return;
3334
}
3435

packages/compass-e2e-tests/helpers/compass.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,8 @@ export async function startBrowser(
781781
'browser.download.folderList': 2,
782782
'browser.download.manager.showWhenStarting': false,
783783
'browser.helperApps.neverAsk.saveToDisk': '*/*',
784+
// Hide the download (progress) panel
785+
'browser.download.alwaysOpenPanel': false,
784786
},
785787
},
786788
},

packages/compass-e2e-tests/helpers/selectors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,7 @@ export const DataModelUndoButton = 'button[aria-label="Undo"]';
14501450
export const DataModelRedoButton = 'button[aria-label="Redo"]';
14511451
export const DataModelExportButton = 'button[aria-label="Export"]';
14521452
export const DataModelExportModal = '[data-testid="export-diagram-modal"]';
1453+
export const DataModelExportPngOption = `${DataModelExportModal} input[aria-label="PNG"]`;
14531454
export const DataModelExportJsonOption = `${DataModelExportModal} input[aria-label="JSON"]`;
14541455
export const DataModelExportModalConfirmButton =
14551456
'[data-testid="export-button"]';

0 commit comments

Comments
 (0)