Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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,75 @@
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('throws if step is NO_DIAGRAM_SELECTED', function () {
expect(() => {
renderDiagramEditorToolbar({ step: 'NO_DIAGRAM_SELECTED' });
}).to.throw;
});

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

Choose a reason for hiding this comment

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

throw needs to be called to assert

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fixed in b65f4c8

});

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,50 @@
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 === 'NO_DIAGRAM_SELECTED') {
throw new Error('Unexpected');
}
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,145 @@
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 exportFormatContainerStyles = css({
display: 'flex',
flexDirection: 'column',
gap: spacing[300],
});

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

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
Collaborator 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
Collaborator

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={exportFormatContainerStyles}>
<Label htmlFor="">Select file format:</Label>
<RadioGroup
className={radioGroupStyles}
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as 'json')}
>
<Radio value="json" aria-label="JSON">
<Icon glyph="CurlyBraces" />
{nbsp}
JSON
</Radio>
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't match the designs where the icon is outside of the radio item, not sure if that's a deliberate choice not to follow the designs here. Also the alignments of items inside the label is slightly off, but catches the eye, in the designs everything is middle aligned, not baseline

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I missed it as I was reusing some of the code from poc. I aligned it with the design in 6c41226

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is how it looks now

image

</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