-
Notifications
You must be signed in to change notification settings - Fork 247
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
Changes from 11 commits
cce62ac
a43ed7e
39793bd
e47687e
dbc1ca0
6d8de76
087936c
6c21b63
b7d8dcf
7c4c14e
f9bcc51
b65f4c8
adc19a1
6c41226
86e8a92
7fa0480
8c5c14d
ffef0b7
467dbbb
69e315f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| }); | ||
|
|
||
| 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 | ||
|---|---|---|---|---|
| @@ -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); | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
|
||||
| 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> | ||||
|
||||
| </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); | ||||

There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed in b65f4c8