diff --git a/package-lock.json b/package-lock.json index 8157b94de2d..46151b10c04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8596,6 +8596,66 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/@mongodb-js/diagramming": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@mongodb-js/diagramming/-/diagramming-1.0.3.tgz", + "integrity": "sha512-AvnBGLVrEGdWJwKIMYnof36ndH2snTLXMGr3DWmuhWCGCwxS0fZVIRfmrmhDZu6I6oUI0kDXxS2P/b20qa1Gig==", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@leafygreen-ui/icon": "^13.2.0", + "@leafygreen-ui/leafygreen-provider": "^4.0.4", + "@leafygreen-ui/palette": "^5.0.0", + "@leafygreen-ui/tokens": "^3.0.0", + "@leafygreen-ui/typography": "^20.1.4", + "@xyflow/react": "^12.5.1", + "d3-path": "^3.1.0", + "elkjs": "^0.10.0", + "react": "17.0.2", + "react-dom": "17.0.2" + } + }, + "node_modules/@mongodb-js/diagramming/node_modules/@leafygreen-ui/emotion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/emotion/-/emotion-5.0.0.tgz", + "integrity": "sha512-MOfouBCmHuFa6UObhUl03CUFqXvD2PP+nI7CLk0ny8/UKOLgAX4N+JuuSX606u+Efxk4lI2m3FZiyCrfi6oeFQ==", + "license": "Apache-2.0", + "dependencies": { + "@emotion/css": "^11.1.3", + "@emotion/server": "^11.4.0" + } + }, + "node_modules/@mongodb-js/diagramming/node_modules/@leafygreen-ui/lib": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-15.0.0.tgz", + "integrity": "sha512-FTUwi67diYnVJsUY9+mcJE9HlPEGTXq5eu6y6daOS1n/qY51sVOhVh8ZNfWuGO0Ztgn6mJJWK/a8I/MwHfoQ7Q==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@mongodb-js/diagramming/node_modules/@leafygreen-ui/palette": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/palette/-/palette-5.0.0.tgz", + "integrity": "sha512-RHQy165X7lKMlNU+2BkvGCNuo8fP3bS5NVOJ6thSKingoksYrz1a6SNAzuHDIkww+njf0GaKiXYT64og2Xm4Fw==", + "license": "Apache-2.0" + }, + "node_modules/@mongodb-js/diagramming/node_modules/@leafygreen-ui/tokens": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/tokens/-/tokens-3.1.0.tgz", + "integrity": "sha512-FctYPmcFYoeLi8zOv7o7MiCCkNxeT7KQ7U4ZT150rYKdIIls8k68vzkePsPtFSTufJ++dSlm7Xbgegl1yAsKfQ==", + "license": "Apache-2.0", + "dependencies": { + "@leafygreen-ui/emotion": "^5.0.0", + "@leafygreen-ui/lib": "^15.0.0", + "@leafygreen-ui/palette": "^5.0.0", + "polished": "^4.2.2" + } + }, "node_modules/@mongodb-js/dl-center": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@mongodb-js/dl-center/-/dl-center-1.3.0.tgz", @@ -44218,7 +44278,7 @@ "@mongodb-js/compass-telemetry": "^1.10.0", "@mongodb-js/compass-user-data": "^0.7.2", "@mongodb-js/compass-workspaces": "^0.42.0", - "@mongodb-js/diagramming": "^1.0.2", + "@mongodb-js/diagramming": "^1.0.3", "bson": "^6.10.3", "compass-preferences-model": "^2.41.0", "lodash": "^4.17.21", @@ -44252,66 +44312,6 @@ "xvfb-maybe": "^0.2.1" } }, - "packages/compass-data-modeling/node_modules/@leafygreen-ui/emotion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/emotion/-/emotion-5.0.0.tgz", - "integrity": "sha512-MOfouBCmHuFa6UObhUl03CUFqXvD2PP+nI7CLk0ny8/UKOLgAX4N+JuuSX606u+Efxk4lI2m3FZiyCrfi6oeFQ==", - "license": "Apache-2.0", - "dependencies": { - "@emotion/css": "^11.1.3", - "@emotion/server": "^11.4.0" - } - }, - "packages/compass-data-modeling/node_modules/@leafygreen-ui/palette": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/palette/-/palette-5.0.0.tgz", - "integrity": "sha512-RHQy165X7lKMlNU+2BkvGCNuo8fP3bS5NVOJ6thSKingoksYrz1a6SNAzuHDIkww+njf0GaKiXYT64og2Xm4Fw==", - "license": "Apache-2.0" - }, - "packages/compass-data-modeling/node_modules/@leafygreen-ui/tokens": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/tokens/-/tokens-3.0.0.tgz", - "integrity": "sha512-o8FQ4l6HAdGZYXpBmsD/6N5yZgul/9isLuLgpzVbzqAnCCoOtJbzgkgF+6BtMe6k3w3UgoRkvV2YIpRikjTLqQ==", - "license": "Apache-2.0", - "dependencies": { - "@leafygreen-ui/emotion": "^5.0.0", - "@leafygreen-ui/lib": "^15.0.0", - "@leafygreen-ui/palette": "^5.0.0", - "polished": "^4.2.2" - } - }, - "packages/compass-data-modeling/node_modules/@leafygreen-ui/tokens/node_modules/@leafygreen-ui/lib": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-15.0.0.tgz", - "integrity": "sha512-FTUwi67diYnVJsUY9+mcJE9HlPEGTXq5eu6y6daOS1n/qY51sVOhVh8ZNfWuGO0Ztgn6mJJWK/a8I/MwHfoQ7Q==", - "license": "Apache-2.0", - "dependencies": { - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0" - } - }, - "packages/compass-data-modeling/node_modules/@mongodb-js/diagramming": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@mongodb-js/diagramming/-/diagramming-1.0.2.tgz", - "integrity": "sha512-CqamSbNSVeSVwTRanDMEZhV62rbJ7GAkRpQB+J8Ch4r/fTeoZ6VcZph/t2wS0zKkZhpO4TGOPUqE5OTPb8AK4A==", - "license": "MIT", - "dependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@leafygreen-ui/icon": "^13.2.0", - "@leafygreen-ui/leafygreen-provider": "^4.0.4", - "@leafygreen-ui/palette": "^5.0.0", - "@leafygreen-ui/tokens": "^3.0.0", - "@leafygreen-ui/typography": "^20.1.4", - "@xyflow/react": "^12.5.1", - "d3-path": "^3.1.0", - "elkjs": "^0.10.0", - "react": "17.0.2", - "react-dom": "17.0.2" - } - }, "packages/compass-data-modeling/node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -57451,7 +57451,7 @@ "@mongodb-js/compass-telemetry": "^1.10.0", "@mongodb-js/compass-user-data": "^0.7.2", "@mongodb-js/compass-workspaces": "^0.42.0", - "@mongodb-js/diagramming": "^1.0.2", + "@mongodb-js/diagramming": "^1.0.3", "@mongodb-js/eslint-config-compass": "^1.3.10", "@mongodb-js/mocha-config-compass": "^1.6.8", "@mongodb-js/prettier-config-compass": "^1.2.8", @@ -57483,60 +57483,6 @@ "xvfb-maybe": "^0.2.1" }, "dependencies": { - "@leafygreen-ui/emotion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/emotion/-/emotion-5.0.0.tgz", - "integrity": "sha512-MOfouBCmHuFa6UObhUl03CUFqXvD2PP+nI7CLk0ny8/UKOLgAX4N+JuuSX606u+Efxk4lI2m3FZiyCrfi6oeFQ==", - "requires": { - "@emotion/css": "^11.1.3", - "@emotion/server": "^11.4.0" - } - }, - "@leafygreen-ui/palette": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/palette/-/palette-5.0.0.tgz", - "integrity": "sha512-RHQy165X7lKMlNU+2BkvGCNuo8fP3bS5NVOJ6thSKingoksYrz1a6SNAzuHDIkww+njf0GaKiXYT64og2Xm4Fw==" - }, - "@leafygreen-ui/tokens": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/tokens/-/tokens-3.0.0.tgz", - "integrity": "sha512-o8FQ4l6HAdGZYXpBmsD/6N5yZgul/9isLuLgpzVbzqAnCCoOtJbzgkgF+6BtMe6k3w3UgoRkvV2YIpRikjTLqQ==", - "requires": { - "@leafygreen-ui/emotion": "^5.0.0", - "@leafygreen-ui/lib": "^15.0.0", - "@leafygreen-ui/palette": "^5.0.0", - "polished": "^4.2.2" - }, - "dependencies": { - "@leafygreen-ui/lib": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-15.0.0.tgz", - "integrity": "sha512-FTUwi67diYnVJsUY9+mcJE9HlPEGTXq5eu6y6daOS1n/qY51sVOhVh8ZNfWuGO0Ztgn6mJJWK/a8I/MwHfoQ7Q==", - "requires": { - "lodash": "^4.17.21" - } - } - } - }, - "@mongodb-js/diagramming": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@mongodb-js/diagramming/-/diagramming-1.0.2.tgz", - "integrity": "sha512-CqamSbNSVeSVwTRanDMEZhV62rbJ7GAkRpQB+J8Ch4r/fTeoZ6VcZph/t2wS0zKkZhpO4TGOPUqE5OTPb8AK4A==", - "requires": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@leafygreen-ui/icon": "^13.2.0", - "@leafygreen-ui/leafygreen-provider": "^4.0.4", - "@leafygreen-ui/palette": "^5.0.0", - "@leafygreen-ui/tokens": "^3.0.0", - "@leafygreen-ui/typography": "^20.1.4", - "@xyflow/react": "^12.5.1", - "d3-path": "^3.1.0", - "elkjs": "^0.10.0", - "react": "17.0.2", - "react-dom": "17.0.2" - } - }, "@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -61077,6 +61023,60 @@ } } }, + "@mongodb-js/diagramming": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@mongodb-js/diagramming/-/diagramming-1.0.3.tgz", + "integrity": "sha512-AvnBGLVrEGdWJwKIMYnof36ndH2snTLXMGr3DWmuhWCGCwxS0fZVIRfmrmhDZu6I6oUI0kDXxS2P/b20qa1Gig==", + "requires": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@leafygreen-ui/icon": "^13.2.0", + "@leafygreen-ui/leafygreen-provider": "^4.0.4", + "@leafygreen-ui/palette": "^5.0.0", + "@leafygreen-ui/tokens": "^3.0.0", + "@leafygreen-ui/typography": "^20.1.4", + "@xyflow/react": "^12.5.1", + "d3-path": "^3.1.0", + "elkjs": "^0.10.0", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "dependencies": { + "@leafygreen-ui/emotion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/emotion/-/emotion-5.0.0.tgz", + "integrity": "sha512-MOfouBCmHuFa6UObhUl03CUFqXvD2PP+nI7CLk0ny8/UKOLgAX4N+JuuSX606u+Efxk4lI2m3FZiyCrfi6oeFQ==", + "requires": { + "@emotion/css": "^11.1.3", + "@emotion/server": "^11.4.0" + } + }, + "@leafygreen-ui/lib": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-15.0.0.tgz", + "integrity": "sha512-FTUwi67diYnVJsUY9+mcJE9HlPEGTXq5eu6y6daOS1n/qY51sVOhVh8ZNfWuGO0Ztgn6mJJWK/a8I/MwHfoQ7Q==", + "requires": { + "lodash": "^4.17.21" + } + }, + "@leafygreen-ui/palette": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/palette/-/palette-5.0.0.tgz", + "integrity": "sha512-RHQy165X7lKMlNU+2BkvGCNuo8fP3bS5NVOJ6thSKingoksYrz1a6SNAzuHDIkww+njf0GaKiXYT64og2Xm4Fw==" + }, + "@leafygreen-ui/tokens": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/tokens/-/tokens-3.1.0.tgz", + "integrity": "sha512-FctYPmcFYoeLi8zOv7o7MiCCkNxeT7KQ7U4ZT150rYKdIIls8k68vzkePsPtFSTufJ++dSlm7Xbgegl1yAsKfQ==", + "requires": { + "@leafygreen-ui/emotion": "^5.0.0", + "@leafygreen-ui/lib": "^15.0.0", + "@leafygreen-ui/palette": "^5.0.0", + "polished": "^4.2.2" + } + } + } + }, "@mongodb-js/dl-center": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@mongodb-js/dl-center/-/dl-center-1.3.0.tgz", diff --git a/packages/compass-data-modeling/package.json b/packages/compass-data-modeling/package.json index dcfb28826eb..dcdc5a6c164 100644 --- a/packages/compass-data-modeling/package.json +++ b/packages/compass-data-modeling/package.json @@ -54,18 +54,18 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "dependencies": { + "@mongodb-js/compass-app-registry": "^9.4.11", "@mongodb-js/compass-app-stores": "^7.47.0", "@mongodb-js/compass-components": "^1.39.0", "@mongodb-js/compass-connections": "^1.61.0", "@mongodb-js/compass-editor": "^0.41.0", - "@mongodb-js/diagramming": "^1.0.2", "@mongodb-js/compass-logging": "^1.7.2", "@mongodb-js/compass-telemetry": "^1.10.0", "@mongodb-js/compass-user-data": "^0.7.2", "@mongodb-js/compass-workspaces": "^0.42.0", + "@mongodb-js/diagramming": "^1.0.3", "bson": "^6.10.3", "compass-preferences-model": "^2.41.0", - "@mongodb-js/compass-app-registry": "^9.4.11", "lodash": "^4.17.21", "mongodb": "^6.14.1", "mongodb-ns": "^2.4.2", diff --git a/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx b/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx new file mode 100644 index 00000000000..a3076233d96 --- /dev/null +++ b/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { expect } from 'chai'; +import { + createPluginTestHelpers, + screen, + waitFor, +} from '@mongodb-js/testing-library-compass'; +import DiagramEditor from './diagram-editor'; +import type { DataModelingStore } from '../../test/setup-store'; +import type { + Edit, + MongoDBDataModelDescription, +} from '../services/data-model-storage'; +import diagramming from '@mongodb-js/diagramming'; +import sinon from 'sinon'; +import { DiagramProvider } from '@mongodb-js/diagramming'; +import { DataModelingWorkspaceTab } from '..'; +import { openDiagram } from '../store/diagram'; + +const storageItems: MongoDBDataModelDescription[] = [ + { + id: 'existing-diagram-id', + name: 'One', + createdAt: '2023-10-01T00:00:00.000Z', + updatedAt: '2023-10-03T00:00:00.000Z', + edits: [ + { + id: 'edit-id-1', + timestamp: '2023-10-02T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db1.collection1', + indexes: [], + displayPosition: [50, 50], + shardKey: {}, + jsonSchema: { bsonType: 'object' }, + }, + { + ns: 'db1.collection2', + indexes: [], + displayPosition: [150, 150], + shardKey: {}, + jsonSchema: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], + connectionId: null, + }, + { + id: 'new-diagram-id', + name: 'Two', + createdAt: '2023-10-01T00:00:00.000Z', + updatedAt: '2023-10-03T00:00:00.000Z', + edits: [ + { + id: 'edit-id-1', + timestamp: '2023-10-02T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db1.collection1', + indexes: [], + displayPosition: [NaN, NaN], + shardKey: {}, + jsonSchema: { bsonType: 'object' }, + }, + { + ns: 'db1.collection2', + indexes: [], + displayPosition: [NaN, NaN], + shardKey: {}, + jsonSchema: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], + connectionId: null, + }, +]; + +const mockDiagramming = { + // Override Diagram import because it's causing esm/cjs interop issues + Diagram: (props: any) => ( +
+ {Object.entries(props).map(([key, value]) => ( +
+ {JSON.stringify(value)} +
+ ))} +
+ ), + applyLayout: (nodes: any) => { + return { + nodes: nodes.map((node: any, index: number) => ({ + ...node, + position: { x: (index + 1) * 100, y: (index + 1) * 100 }, + })), + }; + }, +}; + +const renderDiagramEditor = ({ + items = storageItems, + renderedItem = items[0], +}: { + items?: MongoDBDataModelDescription[]; + renderedItem?: MongoDBDataModelDescription; +} = {}) => { + const mockDataModelStorage = { + status: 'READY', + error: null, + items, + save: () => { + return Promise.resolve(false); + }, + delete: () => { + return Promise.resolve(false); + }, + loadAll: () => Promise.resolve(items), + load: (id: string) => { + return Promise.resolve(items.find((x) => x.id === id) ?? null); + }, + }; + + const { renderWithConnections } = createPluginTestHelpers( + DataModelingWorkspaceTab.provider.withMockServices({ + services: { + dataModelStorage: mockDataModelStorage, + }, + }), + { + namespace: 'foo.bar', + } as any + ); + const { + plugin: { store }, + } = renderWithConnections( + + + + ); + store.dispatch(openDiagram(renderedItem)); + + return { store }; +}; + +describe('DiagramEditor', function () { + let store: DataModelingStore; + + before(function () { + // We need to tub the Diagram import because it has problems with ESM/CJS interop + sinon.stub(diagramming, 'Diagram').callsFake(mockDiagramming.Diagram); + sinon + .stub(diagramming, 'applyLayout') + .callsFake(mockDiagramming.applyLayout as any); + }); + + context('with initial diagram', function () { + beforeEach(async function () { + const result = renderDiagramEditor({ + renderedItem: storageItems[1], + }); + store = result.store; + + // wait till the editor is loaded + await waitFor(() => { + expect(screen.getByTestId('model-preview')).to.be.visible; + }); + }); + + it('applies the initial layout to unpositioned nodes', function () { + const state = store.getState(); + + expect(state.diagram?.edits.current).to.have.lengthOf(1); + expect(state.diagram?.edits.current[0].type).to.equal('SetModel'); + const initialEdit = state.diagram?.edits.current[0] as Extract< + Edit, + { type: 'SetModel' } + >; + expect(initialEdit.model?.collections[0].displayPosition).to.deep.equal([ + 100, 100, + ]); + expect(initialEdit.model?.collections[1].displayPosition).to.deep.equal([ + 200, 200, + ]); + }); + }); + + context('with existing diagram', function () { + beforeEach(async function () { + const result = renderDiagramEditor({ + renderedItem: storageItems[0], + }); + store = result.store; + + // wait till the editor is loaded + await waitFor(() => { + expect(screen.getByTestId('model-preview')).to.be.visible; + }); + }); + + it('does not change the position of the nodes', function () { + const state = store.getState(); + + expect(state.diagram?.edits.current).to.have.lengthOf(1); + expect(state.diagram?.edits.current[0].type).to.equal('SetModel'); + const initialEdit = state.diagram?.edits.current[0] as Extract< + Edit, + { type: 'SetModel' } + >; + const storedEdit = storageItems[0].edits[0] as Extract< + Edit, + { type: 'SetModel' } + >; + expect(initialEdit.model?.collections[0].displayPosition).to.deep.equal( + storedEdit.model.collections[0].displayPosition + ); + expect(initialEdit.model?.collections[1].displayPosition).to.deep.equal( + storedEdit.model.collections[1].displayPosition + ); + }); + }); +}); diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index dc1ef66359c..aaa1f65c7cc 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -1,8 +1,15 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useMemo, + useRef, + useEffect, + useState, +} from 'react'; import { connect } from 'react-redux'; import type { DataModelingState } from '../store/reducer'; import { applyEdit, + applyInitialLayout, getCurrentDiagramFromState, selectCurrentModel, } from '../store/diagram'; @@ -24,11 +31,13 @@ import { type NodeProps, type EdgeProps, useDiagram, + applyLayout, } 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'; +import { useLogger } from '@mongodb-js/compass-logging/provider'; const loadingContainerStyles = css({ width: '100%', @@ -113,6 +122,7 @@ const DiagramEditor: React.FunctionComponent<{ onRetryClick: () => void; onCancelClick: () => void; onApplyClick: (edit: Omit) => void; + onApplyInitialLayout: (positions: Record) => void; }> = ({ diagramLabel, step, @@ -121,10 +131,13 @@ const DiagramEditor: React.FunctionComponent<{ onRetryClick, onCancelClick, onApplyClick, + onApplyInitialLayout, }) => { + const { log, mongoLogId } = useLogger('COMPASS-DATA-MODELING-DIAGRAM-EDITOR'); const isDarkMode = useDarkMode(); const diagramContainerRef = useRef(null); const diagram = useDiagram(); + const [areNodesReady, setAreNodesReady] = useState(false); const setDiagramContainerRef = useCallback( (ref: HTMLDivElement | null) => { @@ -198,7 +211,32 @@ const DiagramEditor: React.FunctionComponent<{ }); }, [model?.relationships]); - const nodes = useMemo(() => { + const applyInitialLayout = useCallback(async () => { + try { + const { nodes: positionedNodes } = await applyLayout( + nodes, + edges, + 'LEFT_RIGHT' + ); + onApplyInitialLayout( + Object.fromEntries( + positionedNodes.map((node) => [ + node.id, + [node.position.x, node.position.y], + ]) + ) + ); + } catch (err) { + log.error( + mongoLogId(1_001_000_361), + 'DiagramEditor', + 'Error applying layout:', + err + ); + } + }, [edges, log, mongoLogId, onApplyInitialLayout]); + + const nodes = useMemo(() => { return (model?.collections ?? []).map( (coll): NodeProps => ({ id: coll.ns, @@ -224,18 +262,29 @@ const DiagramEditor: React.FunctionComponent<{ }; } ), - measured: { - width: 100, - height: 200, - }, }) ); }, [model?.collections]); + useEffect(() => { + if (nodes.length === 0) return; + const isInitialState = nodes.some( + (node) => isNaN(node.position.x) || isNaN(node.position.y) + ); + if (isInitialState) { + void applyInitialLayout(); + return; + } + if (!areNodesReady) { + void diagram.fitView(); + setAreNodesReady(true); + } + }, [areNodesReady, nodes, diagram, applyInitialLayout]); + let content; if (step === 'NO_DIAGRAM_SELECTED') { - throw new Error('Unexpected'); + return null; } if (step === 'ANALYZING') { @@ -279,7 +328,11 @@ const DiagramEditor: React.FunctionComponent<{ isDarkMode={isDarkMode} title={diagramLabel} edges={edges} - nodes={nodes} + nodes={areNodesReady ? nodes : []} + fitViewOptions={{ + maxZoom: 1, + minZoom: 0.25, + }} onEdgeClick={(evt, edge) => { setApplyInput( JSON.stringify( @@ -358,5 +411,6 @@ export default connect( onRetryClick: retryAnalysis, onCancelClick: cancelAnalysis, onApplyClick: applyEdit, + onApplyInitialLayout: applyInitialLayout, } )(DiagramEditor); diff --git a/packages/compass-data-modeling/src/index.spec.tsx b/packages/compass-data-modeling/src/index.spec.tsx index 3aabdc09919..9b4679180f5 100644 --- a/packages/compass-data-modeling/src/index.spec.tsx +++ b/packages/compass-data-modeling/src/index.spec.tsx @@ -1,16 +1,16 @@ import React from 'react'; import { expect } from 'chai'; import { render } from '@mongodb-js/testing-library-compass'; -import { WorkspaceTab } from './index'; +import { DataModelingWorkspaceTab } from './index'; describe('Compass Plugin', function () { - const Plugin = WorkspaceTab.provider.withMockServices({}); + const Plugin = DataModelingWorkspaceTab.provider.withMockServices({}); it('renders a Plugin', function () { expect(() => render( - + ) ).to.not.throw(); diff --git a/packages/compass-data-modeling/src/index.ts b/packages/compass-data-modeling/src/index.ts index 11d8c5db407..a1361e7b8dc 100644 --- a/packages/compass-data-modeling/src/index.ts +++ b/packages/compass-data-modeling/src/index.ts @@ -11,25 +11,27 @@ import { dataModelStorageServiceLocator } from './provider'; import { activateDataModelingStore } from './store'; import { PluginTabTitleComponent, WorkspaceName } from './plugin-tab-title'; -export const WorkspaceTab: WorkspacePlugin = { - name: WorkspaceName, - provider: registerCompassPlugin( - { - name: 'DataModeling', - component: function DataModelingProvider({ children }) { - return React.createElement(React.Fragment, null, children); - }, - activate: activateDataModelingStore, +const CompassDataModelingPluginProvider = registerCompassPlugin( + { + name: 'DataModeling', + component: function DataModelingProvider({ children }) { + return React.createElement(React.Fragment, null, children); }, - { - preferences: preferencesLocator, - connections: connectionsLocator, - instanceManager: mongoDBInstancesManagerLocator, - dataModelStorage: dataModelStorageServiceLocator, - track: telemetryLocator, - logger: createLoggerLocator('COMPASS-DATA-MODELING'), - } - ), + activate: activateDataModelingStore, + }, + { + preferences: preferencesLocator, + connections: connectionsLocator, + instanceManager: mongoDBInstancesManagerLocator, + dataModelStorage: dataModelStorageServiceLocator, + track: telemetryLocator, + logger: createLoggerLocator('COMPASS-DATA-MODELING'), + } +); + +export const DataModelingWorkspaceTab: WorkspacePlugin = { + name: WorkspaceName, + provider: CompassDataModelingPluginProvider, content: DataModelingComponent, header: PluginTabTitleComponent, }; diff --git a/packages/compass-data-modeling/src/services/data-model-storage.ts b/packages/compass-data-modeling/src/services/data-model-storage.ts index 2af8b35012a..b6ab3f417ab 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -27,7 +27,9 @@ export const StaticModelSchema = z.object({ }), indexes: z.array(z.record(z.unknown())), shardKey: z.record(z.unknown()).optional(), - displayPosition: z.tuple([z.number(), z.number()]), + displayPosition: z + .tuple([z.number(), z.number()]) + .or(z.tuple([z.nan(), z.nan()])), }) ), relationships: z.array(RelationshipSchema), @@ -88,7 +90,13 @@ export const MongoDBDataModelDescriptionSchema = z.object({ */ connectionId: z.string().nullable(), - edits: z.array(EditSchema).nonempty(), + // Ensure first item exists and is 'SetModel' + edits: z + .array(EditSchema) + .nonempty() + .refine((edits) => edits[0]?.type === 'SetModel', { + message: "First edit must be of type 'SetModel'", + }), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), diff --git a/packages/compass-data-modeling/src/store/diagram.spec.ts b/packages/compass-data-modeling/src/store/diagram.spec.ts index 14600c15a8d..bcfa0ac055b 100644 --- a/packages/compass-data-modeling/src/store/diagram.spec.ts +++ b/packages/compass-data-modeling/src/store/diagram.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { type DataModelingStore, setupStore } from '../../test/setup-store'; import { applyEdit, + applyInitialLayout, getCurrentDiagramFromState, openDiagram, redoEdit, @@ -67,17 +68,91 @@ describe('Data Modeling store', function () { store = setupStore(); }); - it('openDiagram', function () { - store.dispatch(openDiagram(loadedDiagram)); + describe('New Diagram', function () { + it('handles analysis finished + initial positions', function () { + // ANALYSIS FINISHED + const newDiagram = { + name: 'New Diagram', + connectionId: 'connection-id', + collections: [ + { ns: 'collection1', schema: model.collections[0].jsonSchema }, + { ns: 'collection2', schema: model.collections[1].jsonSchema }, + ], + relations: model.relationships, + }; + store.dispatch({ + type: 'data-modeling/analysis-stats/ANALYSIS_FINISHED', + ...newDiagram, + }); - const diagram = getCurrentDiagramFromState(store.getState()); - expect(diagram.id).to.equal(loadedDiagram.id); - expect(diagram.name).to.equal(loadedDiagram.name); - expect(diagram.connectionId).to.equal(loadedDiagram.connectionId); - expect(diagram.edits).to.deep.equal(loadedDiagram.edits); + const initialDiagram = getCurrentDiagramFromState(store.getState()); + expect(initialDiagram.name).to.equal(newDiagram.name); + expect(initialDiagram.connectionId).to.equal(newDiagram.connectionId); + expect(initialDiagram.edits).to.have.length(1); + expect(initialDiagram.edits[0].type).to.equal('SetModel'); + const initialEdit = initialDiagram.edits[0] as Extract< + Edit, + { type: 'SetModel' } + >; + expect(initialEdit.model.collections[0]).to.deep.include({ + ns: newDiagram.collections[0].ns, + jsonSchema: newDiagram.collections[0].schema, + displayPosition: [NaN, NaN], + }); + expect(initialEdit.model.collections[1]).to.deep.include({ + ns: newDiagram.collections[1].ns, + jsonSchema: newDiagram.collections[1].schema, + displayPosition: [NaN, NaN], + }); + expect(initialEdit.model.relationships).to.deep.equal( + newDiagram.relations + ); + + // INITIAL LAYOUT + const positions: Record = { + [newDiagram.collections[0].ns]: [10, 10], + [newDiagram.collections[1].ns]: [50, 50], + }; + store.dispatch(applyInitialLayout(positions)); + + const diagramWithLayout = getCurrentDiagramFromState(store.getState()); + expect(diagramWithLayout.name).to.equal(newDiagram.name); + expect(diagramWithLayout.connectionId).to.equal(newDiagram.connectionId); + expect(diagramWithLayout.edits).to.have.length(1); + expect(diagramWithLayout.edits[0].type).to.equal('SetModel'); + const initialEditWithPositions = diagramWithLayout.edits[0] as Extract< + Edit, + { type: 'SetModel' } + >; + expect(initialEditWithPositions.model.collections[0]).to.deep.include({ + ns: newDiagram.collections[0].ns, + jsonSchema: newDiagram.collections[0].schema, + displayPosition: positions[newDiagram.collections[0].ns], + }); + expect(initialEditWithPositions.model.collections[1]).to.deep.include({ + ns: newDiagram.collections[1].ns, + jsonSchema: newDiagram.collections[1].schema, + displayPosition: positions[newDiagram.collections[1].ns], + }); + expect(initialEditWithPositions.model.relationships).to.deep.equal( + newDiagram.relations + ); + }); + }); + + describe('Existing Diagram', function () { + it('openDiagram', function () { + store.dispatch(openDiagram(loadedDiagram)); + + const diagram = getCurrentDiagramFromState(store.getState()); + expect(diagram.id).to.equal(loadedDiagram.id); + expect(diagram.name).to.equal(loadedDiagram.name); + expect(diagram.connectionId).to.equal(loadedDiagram.connectionId); + expect(diagram.edits).to.deep.equal(loadedDiagram.edits); + }); }); - describe('applyEdit', function () { + describe('Editing', function () { it('should apply a valid SetModel edit', function () { store.dispatch(openDiagram(loadedDiagram)); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 86abc33022c..46459f4f67e 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -32,6 +32,7 @@ export enum DiagramActionTypes { OPEN_DIAGRAM = 'data-modeling/diagram/OPEN_DIAGRAM', DELETE_DIAGRAM = 'data-modeling/diagram/DELETE_DIAGRAM', RENAME_DIAGRAM = 'data-modeling/diagram/RENAME_DIAGRAM', + APPLY_INITIAL_LAYOUT = 'data-modeling/diagram/APPLY_INITIAL_LAYOUT', APPLY_EDIT = 'data-modeling/diagram/APPLY_EDIT', APPLY_EDIT_FAILED = 'data-modeling/diagram/APPLY_EDIT_FAILED', UNDO_EDIT = 'data-modeling/diagram/UNDO_EDIT', @@ -56,6 +57,11 @@ export type RenameDiagramAction = { name: string; }; +export type ApplyInitialLayoutAction = { + type: DiagramActionTypes.APPLY_INITIAL_LAYOUT; + positions: Record; +}; + export type ApplyEditAction = { type: DiagramActionTypes.APPLY_EDIT; edit: Edit; @@ -86,6 +92,7 @@ export type DiagramActions = | OpenDiagramAction | DeleteDiagramAction | RenameDiagramAction + | ApplyInitialLayoutAction | ApplyEditAction | ApplyEditFailedAction | UndoEditAction @@ -126,11 +133,10 @@ export const diagramReducer: Reducer = ( collections: action.collections.map((collection) => ({ ns: collection.ns, jsonSchema: collection.schema, + displayPosition: [NaN, NaN], // TODO indexes: [], shardKey: undefined, - // TODO: handle correct display position - displayPosition: [Math.random() * 1000, Math.random() * 1000], })), relationships: action.relations, }, @@ -153,6 +159,30 @@ export const diagramReducer: Reducer = ( updatedAt: new Date().toISOString(), }; } + if (isAction(action, DiagramActionTypes.APPLY_INITIAL_LAYOUT)) { + const initialEdit = state.edits.current[0]; + if (!initialEdit || initialEdit.type !== 'SetModel') { + throw new Error('No initial model edit found to apply layout to'); + } + return { + ...state, + edits: { + ...state.edits, + current: [ + { + ...initialEdit, + model: { + ...initialEdit.model, + collections: initialEdit.model.collections.map((collection) => ({ + ...collection, + displayPosition: action.positions[collection.ns] || [NaN, NaN], + })), + }, + }, + ], + }, + }; + } if (isAction(action, DiagramActionTypes.APPLY_EDIT)) { return { ...state, @@ -256,6 +286,18 @@ export function applyEdit( }; } +export function applyInitialLayout( + positions: Record +): DataModelingThunkAction { + return (dispatch, getState, { dataModelStorage }) => { + dispatch({ + type: DiagramActionTypes.APPLY_INITIAL_LAYOUT, + positions, + }); + void dataModelStorage.save(getCurrentDiagramFromState(getState())); + }; +} + export function openDiagram(diagram: MongoDBDataModelDescription) { return { type: DiagramActionTypes.OPEN_DIAGRAM, diagram }; } diff --git a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts index f28ee0cc1ff..ea1e29b11b8 100644 --- a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts +++ b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts @@ -127,6 +127,7 @@ describe('Data Modeling tab', function () { }); const dataModelEditor = browser.$(Selectors.DataModelEditor); + await dataModelEditor.waitForDisplayed(); let nodes = await getDiagramNodes(browser); expect(nodes).to.have.lengthOf(2); diff --git a/packages/compass-web/sandbox/index.tsx b/packages/compass-web/sandbox/index.tsx index 242035a6c6b..b5bb034c0f3 100644 --- a/packages/compass-web/sandbox/index.tsx +++ b/packages/compass-web/sandbox/index.tsx @@ -137,7 +137,7 @@ const App = () => { isAtlas && !!enableGenAIFeaturesAtlasOrg, optInDataExplorerGenAIFeatures: isAtlas && !!optInDataExplorerGenAIFeatures, - enableDataModeling: false, + enableDataModeling: true, }} onTrack={sandboxTelemetry.track} onDebug={sandboxLogger.debug} diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 3e27dd0abc0..7ec34abc395 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -56,7 +56,7 @@ import { useCompassWebLogger } from './logger'; import { type TelemetryServiceOptions } from '@mongodb-js/compass-telemetry'; import { WebWorkspaceTab as WelcomeWorkspaceTab } from '@mongodb-js/compass-welcome'; import { useCompassWebPreferences } from './preferences'; -import { WorkspaceTab as DataModelingWorkspace } from '@mongodb-js/compass-data-modeling'; +import { DataModelingWorkspaceTab as DataModelingWorkspace } from '@mongodb-js/compass-data-modeling'; import { DataModelStorageServiceProviderInMemory } from '@mongodb-js/compass-data-modeling/web'; export type TrackFunction = ( diff --git a/packages/compass/src/app/components/workspace.tsx b/packages/compass/src/app/components/workspace.tsx index cbef0332fce..abb89a43bee 100644 --- a/packages/compass/src/app/components/workspace.tsx +++ b/packages/compass/src/app/components/workspace.tsx @@ -39,7 +39,7 @@ import ExportToLanguageCollectionTabModal from '@mongodb-js/compass-export-to-la import updateTitle from '../utils/update-title'; import { getConnectionTitle } from '@mongodb-js/connection-info'; import { useConnectionsListRef } from '@mongodb-js/compass-connections/provider'; -import { WorkspaceTab as DataModelingWorkspace } from '@mongodb-js/compass-data-modeling'; +import { DataModelingWorkspaceTab } from '@mongodb-js/compass-data-modeling'; export default function Workspace({ appName, @@ -81,7 +81,7 @@ export default function Workspace({ DatabasesWorkspaceTab, CollectionsWorkspaceTab, CollectionWorkspace, - DataModelingWorkspace, + DataModelingWorkspaceTab, ]} >