Skip to content

feat(data-modeling): export diagram to json COMPASS-9448 #7046

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jun 24, 2025
Merged
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
4 changes: 2 additions & 2 deletions packages/compass-components/src/components/toast-body.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Link } from './leafygreen';
import { Link, Body } from './leafygreen';
import { css } from '@leafygreen-ui/emotion';

const toastBodyFlexStyles = css({
Expand Down Expand Up @@ -34,7 +34,7 @@ export function ToastBody({
}) {
return (
<div className={toastBodyFlexStyles}>
<p className={toastBodyTextStyles}>{statusMessage}</p>
<Body className={toastBodyTextStyles}>{statusMessage}</Body>
{!!actionHandler && (
<Link
as="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { expect } from 'chai';
import { render, screen, userEvent } from '@mongodb-js/testing-library-compass';
import { DiagramEditorToolbar } from './diagram-editor-toolbar';
import sinon from 'sinon';

function renderDiagramEditorToolbar(
props: Partial<React.ComponentProps<typeof DiagramEditorToolbar>> = {}
) {
render(
<DiagramEditorToolbar
step="EDITING"
hasUndo={true}
hasRedo={true}
onUndoClick={() => {}}
onRedoClick={() => {}}
onExportClick={() => {}}
{...props}
/>
);
}

describe('DiagramEditorToolbar', function () {
it('renders nothing if step is NO_DIAGRAM_SELECTED', function () {
renderDiagramEditorToolbar({ step: 'NO_DIAGRAM_SELECTED' });
expect(() => screen.getByTestId('diagram-editor-toolbar')).to.throw();
});

it('renders nothing if step is not EDITING', function () {
renderDiagramEditorToolbar({ step: 'ANALYSIS_CANCELED' });
expect(() => screen.getByTestId('diagram-editor-toolbar')).to.throw();
});

context('undo button', function () {
it('renders it disabled if hasUndo is false', function () {
renderDiagramEditorToolbar({ hasUndo: false });
const undoButton = screen.getByRole('button', { name: 'Undo' });
expect(undoButton).to.have.attribute('aria-disabled', 'true');
});
it('renders it enabled if hasUndo is true and calls onUndoClick', function () {
const undoSpy = sinon.spy();
renderDiagramEditorToolbar({ hasUndo: true, onUndoClick: undoSpy });
const undoButton = screen.getByRole('button', { name: 'Undo' });
expect(undoButton).to.have.attribute('aria-disabled', 'false');
userEvent.click(undoButton);
expect(undoSpy).to.have.been.calledOnce;
});
});

context('redo button', function () {
it('renders it disabled if hasRedo is false', function () {
renderDiagramEditorToolbar({ hasRedo: false });
const redoButton = screen.getByRole('button', { name: 'Redo' });
expect(redoButton).to.have.attribute('aria-disabled', 'true');
});
it('renders it enabled if hasRedo is true and calls onRedoClick', function () {
const redoSpy = sinon.spy();
renderDiagramEditorToolbar({ hasRedo: true, onRedoClick: redoSpy });
const redoButton = screen.getByRole('button', { name: 'Redo' });
expect(redoButton).to.have.attribute('aria-disabled', 'false');
userEvent.click(redoButton);
expect(redoSpy).to.have.been.calledOnce;
});
});

it('renders export buttona and calls onExportClick', function () {
const exportSpy = sinon.spy();
renderDiagramEditorToolbar({ onExportClick: exportSpy });
const exportButton = screen.getByRole('button', { name: 'Export' });
expect(exportButton).to.exist;
userEvent.click(exportButton);
expect(exportSpy).to.have.been.calledOnce;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { connect } from 'react-redux';
import type { DataModelingState } from '../store/reducer';
import { redoEdit, showExportModal, undoEdit } from '../store/diagram';
import { Icon, IconButton } from '@mongodb-js/compass-components';

export const DiagramEditorToolbar: React.FunctionComponent<{
step: DataModelingState['step'];
hasUndo: boolean;
hasRedo: boolean;
onUndoClick: () => void;
onRedoClick: () => void;
onExportClick: () => void;
}> = ({ step, hasUndo, onUndoClick, hasRedo, onRedoClick, onExportClick }) => {
if (step !== 'EDITING') {
return null;
}
return (
<div data-testid="diagram-editor-toolbar">
<IconButton aria-label="Undo" disabled={!hasUndo} onClick={onUndoClick}>
<Icon glyph="Undo"></Icon>
</IconButton>
<IconButton aria-label="Redo" disabled={!hasRedo} onClick={onRedoClick}>
<Icon glyph="Redo"></Icon>
</IconButton>
<IconButton aria-label="Export" onClick={onExportClick}>
<Icon glyph="Export"></Icon>
</IconButton>
</div>
);
};

export default connect(
(state: DataModelingState) => {
const { diagram, step } = state;
return {
step: step,
hasUndo: (diagram?.edits.prev.length ?? 0) > 0,
hasRedo: (diagram?.edits.next.length ?? 0) > 0,
};
},
{
onUndoClick: undoEdit,
onRedoClick: redoEdit,
onExportClick: showExportModal,
}
)(DiagramEditorToolbar);
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,10 @@ import type { DataModelingState } from '../store/reducer';
import {
applyEdit,
getCurrentDiagramFromState,
redoEdit,
selectCurrentModel,
undoEdit,
} from '../store/diagram';
import {
Banner,
Icon,
IconButton,
CancelLoader,
WorkspaceContainer,
css,
Expand All @@ -31,6 +27,8 @@ import {
} from '@mongodb-js/diagramming';
import type { Edit, StaticModel } from '../services/data-model-storage';
import { UUID } from 'bson';
import DiagramEditorToolbar from './diagram-editor-toolbar';
import ExportDiagramModal from './export-diagram-modal';

const loadingContainerStyles = css({
width: '100%',
Expand Down Expand Up @@ -110,10 +108,6 @@ const editorContainerPlaceholderButtonStyles = css({
const DiagramEditor: React.FunctionComponent<{
diagramLabel: string;
step: DataModelingState['step'];
hasUndo: boolean;
onUndoClick: () => void;
hasRedo: boolean;
onRedoClick: () => void;
model: StaticModel | null;
editErrors?: string[];
onRetryClick: () => void;
Expand All @@ -122,10 +116,6 @@ const DiagramEditor: React.FunctionComponent<{
}> = ({
diagramLabel,
step,
hasUndo,
onUndoClick,
hasRedo,
onRedoClick,
model,
editErrors,
onRetryClick,
Expand Down Expand Up @@ -345,33 +335,9 @@ const DiagramEditor: React.FunctionComponent<{
}

return (
<WorkspaceContainer
toolbar={() => {
if (step !== 'EDITING') {
return null;
}

return (
<>
<IconButton
aria-label="Undo"
disabled={!hasUndo}
onClick={onUndoClick}
>
<Icon glyph="Undo"></Icon>
</IconButton>
<IconButton
aria-label="Redo"
disabled={!hasRedo}
onClick={onRedoClick}
>
<Icon glyph="Redo"></Icon>
</IconButton>
</>
);
}}
>
<WorkspaceContainer toolbar={<DiagramEditorToolbar />}>
{content}
<ExportDiagramModal />
</WorkspaceContainer>
);
};
Expand All @@ -381,8 +347,6 @@ export default connect(
const { diagram, step } = state;
return {
step: step,
hasUndo: (diagram?.edits.prev.length ?? 0) > 0,
hasRedo: (diagram?.edits.next.length ?? 0) > 0,
model: diagram
? selectCurrentModel(getCurrentDiagramFromState(state))
: null,
Expand All @@ -391,8 +355,6 @@ export default connect(
};
},
{
onUndoClick: undoEdit,
onRedoClick: redoEdit,
onRetryClick: retryAnalysis,
onCancelClick: cancelAnalysis,
onApplyClick: applyEdit,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React, { useCallback, useState } from 'react';
import {
Button,
css,
Icon,
Label,
Link,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Radio,
RadioGroup,
spacing,
} from '@mongodb-js/compass-components';
import {
closeExportModal,
selectCurrentModel,
getCurrentDiagramFromState,
} from '../store/diagram';
import { connect } from 'react-redux';
import type { DataModelingState } from '../store/reducer';
import type { StaticModel } from '../services/data-model-storage';
import { exportToJson } from '../services/export-diagram';

const nbsp = '\u00a0';

const modelBodyStyles = css({
paddingTop: spacing[600],
});

const contentContainerStyles = css({
display: 'flex',
flexDirection: 'column',
gap: spacing[300],
});

const radioItemStyles = css({
display: 'flex',
alignItems: 'center',
gap: spacing[200],
});

const footerStyles = css({
display: 'flex',
gap: spacing[200],
});

type ExportDiagramModalProps = {
isModalOpen: boolean;
diagramLabel: string;
model: StaticModel | null;
onCloseClick: () => void;
};

const ExportDiagramModal = ({
isModalOpen,
diagramLabel,
model,
onCloseClick,
}: ExportDiagramModalProps) => {
const [exportFormat, setExportFormat] = useState<'json' | null>(null);

const onExport = useCallback(() => {
if (!exportFormat || !model) {
return;
}
exportToJson(diagramLabel, model);
Copy link
Member

@Anemy Anemy Jun 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we try/catch this and show the error to the user? I'm wondering what happens if a user tries to save in a place where they might not have permissions or another error with file system saving that could happen like no space left.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that will be handled by the toasts which we show for file downloads as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, both success and failure will be handled on the app level (for electron)

session.defaultSession.on(

onCloseClick();
}, [exportFormat, onCloseClick, model, diagramLabel]);

return (
<Modal
open={isModalOpen}
setOpen={onCloseClick}
data-testid="export-diagram-modal"
>
<ModalHeader
title="Export data model"
subtitle={
<div>
Export the data modal to JSON format.
{nbsp}
<Link
href="https://www.mongodb.com/docs/manual/data-modeling//"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</Link>
</div>
}
/>
<ModalBody className={modelBodyStyles}>
<div className={contentContainerStyles}>
<Label htmlFor="">Select file format:</Label>
<RadioGroup className={contentContainerStyles} value={exportFormat}>
<div className={radioItemStyles}>
<Icon glyph="CurlyBraces" />
<Radio
checked={exportFormat === 'json'}
value="json"
aria-label="JSON"
onClick={() => setExportFormat('json')}
>
JSON
</Radio>
</div>
</RadioGroup>
</div>
</ModalBody>
<ModalFooter className={footerStyles}>
<Button
variant="primary"
onClick={() => void onExport()}
data-testid="export-button"
>
Export
</Button>
<Button
variant="default"
onClick={onCloseClick}
data-testid="cancel-button"
>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};

export default connect(
(state: DataModelingState) => {
const { diagram } = state;
const model = diagram
? selectCurrentModel(getCurrentDiagramFromState(state))
: null;
return {
model,
diagramLabel: diagram?.name ?? 'Schema Preview',
isModalOpen: Boolean(diagram?.isExportModalOpen),
};
},
{
onCloseClick: closeExportModal,
}
)(ExportDiagramModal);
Loading
Loading