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; });