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,
]}
>