diff --git a/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx b/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx
index 4be44eeacfd..67df93d6391 100644
--- a/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx
+++ b/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx
@@ -93,7 +93,7 @@ const mockDiagramming = {
{Object.entries(props).map(([key, value]) => (
- {JSON.stringify(value)}
+ {typeof value === 'object' ? 'object' : JSON.stringify(value)}
))}
diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx
index eb82e1569b0..6f9bb483649 100644
--- a/packages/compass-data-modeling/src/components/diagram-editor.tsx
+++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx
@@ -8,6 +8,7 @@ import React, {
import { connect } from 'react-redux';
import type { DataModelingState } from '../store/reducer';
import {
+ addNewFieldToCollection,
moveCollection,
selectCollection,
selectRelationship,
@@ -111,6 +112,7 @@ const DiagramContent: React.FunctionComponent<{
isInRelationshipDrawingMode: boolean;
editErrors?: string[];
newCollection?: string;
+ onAddNewFieldToCollection: (ns: string) => void;
onMoveCollection: (ns: string, newPosition: [number, number]) => void;
onCollectionSelect: (namespace: string) => void;
onRelationshipSelect: (rId: string) => void;
@@ -130,6 +132,7 @@ const DiagramContent: React.FunctionComponent<{
model,
isInRelationshipDrawingMode,
newCollection,
+ onAddNewFieldToCollection,
onMoveCollection,
onCollectionSelect,
onRelationshipSelect,
@@ -171,17 +174,21 @@ const DiagramContent: React.FunctionComponent<{
!!selectedItems &&
selectedItems.type === 'collection' &&
selectedItems.id === coll.ns;
- return collectionToDiagramNode(coll, {
+ return collectionToDiagramNode({
+ ...coll,
highlightedFields,
selectedField:
selectedItems?.type === 'field' && selectedItems.namespace === coll.ns
? selectedItems.fieldPath
: undefined,
+ onClickAddNewFieldToCollection: () =>
+ onAddNewFieldToCollection(coll.ns),
selected,
isInRelationshipDrawingMode,
});
});
}, [
+ onAddNewFieldToCollection,
model?.collections,
model?.relationships,
selectedItems,
@@ -301,6 +308,7 @@ const ConnectedDiagramContent = connect(
};
},
{
+ onAddNewFieldToCollection: addNewFieldToCollection,
onMoveCollection: moveCollection,
onCollectionSelect: selectCollection,
onRelationshipSelect: selectRelationship,
diff --git a/packages/compass-data-modeling/src/components/icons/plus-with-square.tsx b/packages/compass-data-modeling/src/components/icons/plus-with-square.tsx
new file mode 100644
index 00000000000..6977f9f77a9
--- /dev/null
+++ b/packages/compass-data-modeling/src/components/icons/plus-with-square.tsx
@@ -0,0 +1,27 @@
+import React, { useMemo } from 'react';
+import { palette, useDarkMode } from '@mongodb-js/compass-components';
+
+const PlusWithSquare: React.FunctionComponent = () => {
+ const darkMode = useDarkMode();
+ const strokeColor = useMemo(
+ () => (darkMode ? palette.gray.light1 : palette.gray.dark1),
+ [darkMode]
+ );
+
+ return (
+
+ );
+};
+
+export default PlusWithSquare;
diff --git a/packages/compass-data-modeling/src/store/analysis-process.ts b/packages/compass-data-modeling/src/store/analysis-process.ts
index 48cbec31c19..6e14705dec2 100644
--- a/packages/compass-data-modeling/src/store/analysis-process.ts
+++ b/packages/compass-data-modeling/src/store/analysis-process.ts
@@ -7,7 +7,7 @@ import type { Document } from 'bson';
import type { AggregationCursor } from 'mongodb';
import type { Relationship } from '../services/data-model-storage';
import { applyLayout } from '@mongodb-js/diagramming';
-import { collectionToDiagramNode } from '../utils/nodes-and-edges';
+import { collectionToBaseNodeForLayout } from '../utils/nodes-and-edges';
export type AnalysisProcessState = {
currentAnalysisOptions:
@@ -207,13 +207,13 @@ export function startAnalysis(
}
const positioned = await applyLayout(
- collections.map((coll) => {
- return collectionToDiagramNode({
+ collections.map((coll) =>
+ collectionToBaseNodeForLayout({
ns: coll.ns,
jsonSchema: coll.schema,
displayPosition: [0, 0],
- });
- }),
+ })
+ ),
[],
'LEFT_RIGHT'
);
diff --git a/packages/compass-data-modeling/src/store/apply-edit.ts b/packages/compass-data-modeling/src/store/apply-edit.ts
index 3e9d4c1d2aa..908efd81f4b 100644
--- a/packages/compass-data-modeling/src/store/apply-edit.ts
+++ b/packages/compass-data-modeling/src/store/apply-edit.ts
@@ -4,6 +4,7 @@ import type {
Relationship,
StaticModel,
} from '../services/data-model-storage';
+import { addFieldToJSONSchema } from '../utils/schema';
import { updateSchema } from '../utils/schema-traversal';
import {
isRelationshipInvolvingField,
@@ -149,6 +150,24 @@ export function applyEdit(edit: Edit, model?: StaticModel): StaticModel {
}),
};
}
+ case 'AddField': {
+ return {
+ ...model,
+ collections: model.collections.map((collection) => {
+ if (collection.ns === edit.ns) {
+ return {
+ ...collection,
+ jsonSchema: addFieldToJSONSchema(
+ collection.jsonSchema,
+ edit.field,
+ edit.jsonSchema
+ ),
+ };
+ }
+ return collection;
+ }),
+ };
+ }
case 'RemoveField': {
return {
...model,
diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts
index 883328112ff..f66d44ce5c9 100644
--- a/packages/compass-data-modeling/src/store/diagram.ts
+++ b/packages/compass-data-modeling/src/store/diagram.ts
@@ -27,10 +27,11 @@ import {
} from '../services/open-and-download-diagram';
import type { MongoDBJSONSchema } from 'mongodb-schema';
import { getCoordinatesForNewNode } from '@mongodb-js/diagramming';
-import { collectionToDiagramNode } from '../utils/nodes-and-edges';
+import { collectionToBaseNodeForLayout } from '../utils/nodes-and-edges';
import toNS from 'mongodb-ns';
import { traverseSchema } from '../utils/schema-traversal';
import { applyEdit as _applyEdit } from './apply-edit';
+import { getNewUnusedFieldName } from '../utils/schema';
function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] {
return Array.isArray(arr) && arr.length > 0;
@@ -394,6 +395,13 @@ const updateSelectedItemsFromAppliedEdit = (
],
};
}
+ case 'AddField': {
+ return {
+ type: 'field',
+ namespace: edit.ns,
+ fieldPath: edit.field,
+ };
+ }
}
return currentSelection;
@@ -485,6 +493,34 @@ export function redoEdit(): DataModelingThunkAction {
};
}
+export function addNewFieldToCollection(
+ ns: string
+): DataModelingThunkAction {
+ return (dispatch, getState) => {
+ const modelState = selectCurrentModelFromState(getState());
+
+ const collection = modelState.collections.find((c) => c.ns === ns);
+ if (!collection) {
+ throw new Error('Collection to add field to not found');
+ }
+
+ const edit: Omit<
+ Extract,
+ 'id' | 'timestamp'
+ > = {
+ type: 'AddField',
+ ns,
+ // Use the first unique field name we can use.
+ field: [getNewUnusedFieldName(collection.jsonSchema)],
+ jsonSchema: {
+ bsonType: 'string',
+ },
+ };
+
+ return dispatch(applyEdit(edit));
+ };
+}
+
export function moveCollection(
ns: string,
newPosition: [number, number]
@@ -507,18 +543,16 @@ export function renameCollection(
void,
ApplyEditAction | ApplyEditFailedAction | CollectionSelectedAction
> {
- return (dispatch) => {
- const edit: Omit<
- Extract,
- 'id' | 'timestamp'
- > = {
- type: 'RenameCollection',
- fromNS,
- toNS,
- };
-
- dispatch(applyEdit(edit));
+ const edit: Omit<
+ Extract,
+ 'id' | 'timestamp'
+ > = {
+ type: 'RenameCollection',
+ fromNS,
+ toNS,
};
+
+ return applyEdit(edit);
}
export function applyEdit(
@@ -689,9 +723,9 @@ function getPositionForNewCollection(
newCollection: Omit
): [number, number] {
const existingNodes = existingCollections.map((collection) =>
- collectionToDiagramNode(collection)
+ collectionToBaseNodeForLayout(collection)
);
- const newNode = collectionToDiagramNode({
+ const newNode = collectionToBaseNodeForLayout({
ns: newCollection.ns,
jsonSchema: newCollection.jsonSchema,
displayPosition: [0, 0],
diff --git a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx
index d8f800b0a52..fb49b7f4977 100644
--- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx
+++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx
@@ -1,7 +1,12 @@
import React from 'react';
import toNS from 'mongodb-ns';
-import { InlineDefinition, Body, css } from '@mongodb-js/compass-components';
-import type { NodeProps, EdgeProps } from '@mongodb-js/diagramming';
+import {
+ Body,
+ IconButton,
+ InlineDefinition,
+ css,
+} from '@mongodb-js/compass-components';
+import type { NodeProps, EdgeProps, BaseNode } from '@mongodb-js/diagramming';
import type { MongoDBJSONSchema } from 'mongodb-schema';
import type { SelectedItems } from '../store/diagram';
import type {
@@ -11,6 +16,7 @@ import type {
} from '../services/data-model-storage';
import { traverseSchema } from './schema-traversal';
import { areFieldPathsEqual } from './utils';
+import PlusWithSquare from '../components/icons/plus-with-square';
function getBsonTypeName(bsonType: string) {
switch (bsonType) {
@@ -21,6 +27,10 @@ function getBsonTypeName(bsonType: string) {
}
}
+const addNewFieldStyles = css({
+ marginLeft: 'auto',
+});
+
const mixedTypeTooltipContentStyles = css({
overflowWrap: 'anywhere',
textWrap: 'wrap',
@@ -116,38 +126,77 @@ export const getFieldsFromSchema = ({
return fields;
};
-export function collectionToDiagramNode(
- coll: Pick,
- options: {
- highlightedFields?: Record;
- selectedField?: FieldPath;
- selected?: boolean;
- isInRelationshipDrawingMode?: boolean;
- } = {}
-): NodeProps {
- const {
- highlightedFields = {},
- selectedField,
- selected = false,
- isInRelationshipDrawingMode = false,
- } = options;
+/**
+ * Create a base node to be used for positioning and measuring in node layouts.
+ */
+export function collectionToBaseNodeForLayout({
+ ns,
+ jsonSchema,
+ displayPosition,
+}: Pick<
+ DataModelCollection,
+ 'ns' | 'jsonSchema' | 'displayPosition'
+>): BaseNode & Pick {
+ return {
+ id: ns,
+ position: {
+ x: displayPosition[0],
+ y: displayPosition[1],
+ },
+ fields: getFieldsFromSchema({ jsonSchema }),
+ };
+}
+type CollectionWithRenderOptions = Pick<
+ DataModelCollection,
+ 'ns' | 'jsonSchema' | 'displayPosition'
+> & {
+ highlightedFields: Record;
+ selectedField?: FieldPath;
+ selected: boolean;
+ isInRelationshipDrawingMode: boolean;
+ onClickAddNewFieldToCollection: () => void;
+};
+
+export function collectionToDiagramNode({
+ ns,
+ jsonSchema,
+ displayPosition,
+ selectedField,
+ highlightedFields,
+ selected,
+ isInRelationshipDrawingMode,
+ onClickAddNewFieldToCollection,
+}: CollectionWithRenderOptions): NodeProps {
return {
- id: coll.ns,
+ id: ns,
type: 'collection',
position: {
- x: coll.displayPosition[0],
- y: coll.displayPosition[1],
+ x: displayPosition[0],
+ y: displayPosition[1],
},
- title: toNS(coll.ns).collection,
+ title: toNS(ns).collection,
fields: getFieldsFromSchema({
- jsonSchema: coll.jsonSchema,
- highlightedFields: highlightedFields[coll.ns] ?? undefined,
+ jsonSchema: jsonSchema,
+ highlightedFields: highlightedFields[ns] ?? undefined,
selectedField,
}),
selected,
connectable: isInRelationshipDrawingMode,
draggable: !isInRelationshipDrawingMode,
+ actions: onClickAddNewFieldToCollection ? (
+ ) => {
+ event.stopPropagation();
+ onClickAddNewFieldToCollection();
+ }}
+ title="Add Field"
+ >
+
+
+ ) : undefined,
};
}
diff --git a/packages/compass-data-modeling/src/utils/schema.spec.ts b/packages/compass-data-modeling/src/utils/schema.spec.ts
new file mode 100644
index 00000000000..e6a923b1f92
--- /dev/null
+++ b/packages/compass-data-modeling/src/utils/schema.spec.ts
@@ -0,0 +1,122 @@
+import { expect } from 'chai';
+import { addFieldToJSONSchema, getNewUnusedFieldName } from './schema';
+
+describe('schema diagram utils', function () {
+ describe('#getNewUnusedFieldName', function () {
+ it('should return a new unused field name', function () {
+ const jsonSchema = {
+ bsonType: 'object',
+ properties: {
+ a: {
+ bsonType: 'string',
+ },
+ b: {
+ bsonType: 'string',
+ },
+ },
+ };
+ const newFieldName = getNewUnusedFieldName(jsonSchema);
+ expect(newFieldName).to.equal('field-1');
+ });
+
+ it('should return a new unused field name when there are conflicts', function () {
+ const jsonSchema = {
+ bsonType: 'object',
+ properties: {
+ 'field-1': {
+ bsonType: 'string',
+ },
+ 'field-2': {
+ bsonType: 'string',
+ },
+ },
+ };
+ const newFieldName = getNewUnusedFieldName(jsonSchema);
+ expect(newFieldName).to.equal('field-3');
+ });
+ });
+
+ describe('#addFieldToJSONSchema', function () {
+ it('should add a field to the root of the schema', function () {
+ const jsonSchema = {
+ bsonType: 'object',
+ properties: {
+ a: {
+ bsonType: 'string',
+ },
+ b: {
+ bsonType: 'string',
+ },
+ },
+ };
+ const newFieldSchema = {
+ bsonType: 'string',
+ };
+ const newJsonSchema = addFieldToJSONSchema(
+ jsonSchema,
+ ['c'],
+ newFieldSchema
+ );
+ expect(newJsonSchema).to.deep.equal({
+ bsonType: 'object',
+ properties: {
+ a: {
+ bsonType: 'string',
+ },
+ b: {
+ bsonType: 'string',
+ },
+ c: {
+ bsonType: 'string',
+ },
+ },
+ });
+ });
+
+ it('should add a field to a nested object in the schema', function () {
+ const jsonSchema = {
+ bsonType: 'object',
+ properties: {
+ a: {
+ bsonType: 'string',
+ },
+ b: {
+ bsonType: 'object',
+ properties: {
+ c: {
+ bsonType: 'string',
+ },
+ },
+ },
+ },
+ };
+ const newFieldSchema = {
+ bsonType: 'string',
+ };
+ const newJsonSchema = addFieldToJSONSchema(
+ jsonSchema,
+ ['b', 'd'],
+ newFieldSchema
+ );
+ expect(newJsonSchema).to.deep.equal({
+ bsonType: 'object',
+ properties: {
+ a: {
+ bsonType: 'string',
+ },
+ b: {
+ bsonType: 'object',
+ properties: {
+ c: {
+ bsonType: 'string',
+ },
+ d: {
+ bsonType: 'string',
+ },
+ },
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/packages/compass-data-modeling/src/utils/schema.ts b/packages/compass-data-modeling/src/utils/schema.ts
new file mode 100644
index 00000000000..71e6332663f
--- /dev/null
+++ b/packages/compass-data-modeling/src/utils/schema.ts
@@ -0,0 +1,51 @@
+import type { MongoDBJSONSchema } from 'mongodb-schema';
+
+export function getNewUnusedFieldName(jsonSchema: MongoDBJSONSchema): string {
+ const existingFieldNames = new Set(Object.keys(jsonSchema.properties || {}));
+ let i = 1;
+ let fieldName = `field-${i}`;
+
+ while (existingFieldNames.has(fieldName)) {
+ i++;
+ fieldName = `field-${i}`;
+ }
+
+ return fieldName;
+}
+
+export function addFieldToJSONSchema(
+ jsonSchema: MongoDBJSONSchema,
+ fieldPath: string[],
+ newFieldSchema: MongoDBJSONSchema
+): MongoDBJSONSchema {
+ if (fieldPath.length === 0) {
+ throw new Error('Invalid field to add to schema');
+ }
+
+ if (fieldPath.length === 1) {
+ return {
+ ...jsonSchema,
+ properties: {
+ ...jsonSchema.properties,
+ [fieldPath[0]]: newFieldSchema,
+ },
+ };
+ }
+
+ const schemaToAddFieldTo = jsonSchema.properties?.[fieldPath[0]];
+ if (!schemaToAddFieldTo) {
+ throw new Error('Field path to add new field to does not exist');
+ }
+
+ return {
+ ...jsonSchema,
+ properties: {
+ ...jsonSchema.properties,
+ [fieldPath[0]]: addFieldToJSONSchema(
+ schemaToAddFieldTo,
+ fieldPath.slice(1),
+ newFieldSchema
+ ),
+ },
+ };
+}
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 0d7bc9d8338..3dae04dbb3a 100644
--- a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts
+++ b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts
@@ -24,10 +24,10 @@ import os from 'os';
import fs from 'fs/promises';
import type { ChainablePromiseElement } from 'webdriverio';
-interface Node {
+type Node = {
id: string;
position: { x: number; y: number };
-}
+};
interface Edge {
id: string;
@@ -162,9 +162,17 @@ async function getDiagramNodes(
if (!node) {
throw new Error(`Element with selector ${selector} not found`);
}
- return (
- node as Element & { _diagram: DiagramInstance }
- )._diagram.getNodes();
+
+ return (node as Element & { _diagram: DiagramInstance })._diagram
+ .getNodes()
+ .map(
+ (node: Node): Node => ({
+ // do not add any non-serializable properties here,
+ // the result of browser.execute must be serializable
+ id: node.id,
+ position: node.position,
+ })
+ );
}, Selectors.DataModelEditor);
return nodes.length === expectedCount;
});
@@ -182,9 +190,20 @@ async function getDiagramEdges(
if (!node) {
throw new Error(`Element with selector ${selector} not found`);
}
- return (
- node as Element & { _diagram: DiagramInstance }
- )._diagram.getEdges();
+ return (node as Element & { _diagram: DiagramInstance })._diagram
+ .getEdges()
+ .map(
+ (edge: Edge): Edge => ({
+ // do not add any non-serializable properties here,
+ // the result of browser.execute must be serializable
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ markerStart: edge.markerStart,
+ markerEnd: edge.markerEnd,
+ selected: edge.selected,
+ })
+ );
}, Selectors.DataModelEditor);
return edges.length === expectedCount;
});