From 7e945730856c6839cf45819353f2a87ff24d3c9e Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Wed, 20 Aug 2025 14:30:51 +0200 Subject: [PATCH 01/19] feat: field sidebar COMPASS-9659 --- .../src/components/diagram-editor.tsx | 12 +- .../drawer/collection-drawer-content.tsx | 127 ++---------------- .../drawer/diagram-editor-side-panel.tsx | 57 +++++++- .../src/services/data-model-storage.ts | 46 +++++++ .../src/store/diagram.ts | 44 +++++- .../src/utils/nodes-and-edges.tsx | 11 +- 6 files changed, 169 insertions(+), 128 deletions(-) diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 73b51358fc0..a82ab57fa5c 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -16,6 +16,7 @@ import { selectCurrentModelFromState, createNewRelationship, addCollection, + selectField, } from '../store/diagram'; import { Banner, @@ -36,7 +37,7 @@ import { type EdgeProps, useDiagram, } from '@mongodb-js/diagramming'; -import type { StaticModel } from '../services/data-model-storage'; +import type { FieldPath, StaticModel } from '../services/data-model-storage'; import DiagramEditorToolbar from './diagram-editor-toolbar'; import ExportDiagramModal from './export-diagram-modal'; import { DATA_MODELING_DRAWER_ID } from './drawer/diagram-editor-side-panel'; @@ -113,6 +114,7 @@ const DiagramContent: React.FunctionComponent<{ onMoveCollection: (ns: string, newPosition: [number, number]) => void; onCollectionSelect: (namespace: string) => void; onRelationshipSelect: (rId: string) => void; + onFieldSelect: (namespace: string, fieldPath: FieldPath) => void; onDiagramBackgroundClicked: () => void; selectedItems: SelectedItems; onCreateNewRelationship: (source: string, target: string) => void; @@ -125,6 +127,7 @@ const DiagramContent: React.FunctionComponent<{ onMoveCollection, onCollectionSelect, onRelationshipSelect, + onFieldSelect, onDiagramBackgroundClicked, onCreateNewRelationship, onRelationshipDrawn, @@ -252,6 +255,12 @@ const DiagramContent: React.FunctionComponent<{ onRelationshipSelect(edge.id); openDrawer(DATA_MODELING_DRAWER_ID); }} + onFieldClick={(_evt, { id: fieldPath, nodeId: namespace }) => { + _evt.stopPropagation(); // TODO: should this be handled by the diagramming package? + if (!Array.isArray(fieldPath)) return; // TODO: could be avoided with generics in the diagramming package + onFieldSelect(namespace, fieldPath); + openDrawer(DATA_MODELING_DRAWER_ID); + }} fitViewOptions={{ maxZoom: 1, minZoom: 0.25, @@ -282,6 +291,7 @@ const ConnectedDiagramContent = connect( onMoveCollection: moveCollection, onCollectionSelect: selectCollection, onRelationshipSelect: selectRelationship, + onFieldSelect: selectField, onDiagramBackgroundClicked: selectBackground, onCreateNewRelationship: createNewRelationship, } diff --git a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx index 8a0c753fb96..3b6661c0ce2 100644 --- a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx @@ -1,21 +1,8 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { connect } from 'react-redux'; import toNS from 'mongodb-ns'; -import type { - Relationship, - DataModelCollection, -} from '../../services/data-model-storage'; -import { - Badge, - Button, - IconButton, - css, - palette, - spacing, - TextInput, - Icon, - TextArea, -} from '@mongodb-js/compass-components'; +import type { DataModelCollection, Relationship } from '../../services/data-model-storage'; +import { TextInput, TextArea } from '@mongodb-js/compass-components'; import { createNewRelationship, deleteRelationship, @@ -31,6 +18,7 @@ import { DMFormFieldContainer, } from './drawer-section-components'; import { useChangeOnBlur } from './use-change-on-blur'; +import { RelationshipsSection } from './relationships-section'; type CollectionDrawerContentProps = { namespace: string; @@ -45,39 +33,6 @@ type CollectionDrawerContentProps = { onRenameCollection: (fromNS: string, toNS: string) => void; }; -const titleBtnStyles = css({ - marginLeft: 'auto', - maxHeight: 20, // To match accordion line height -}); - -const emptyRelationshipMessageStyles = css({ - color: palette.gray.dark1, -}); - -const relationshipsListStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[200], -}); - -const relationshipItemStyles = css({ - display: 'flex', - alignItems: 'center', -}); - -const relationshipNameStyles = css({ - flexGrow: 1, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - minWidth: 0, - paddingRight: spacing[200], -}); - -const relationshipContentStyles = css({ - marginTop: spacing[400], -}); - export function getIsCollectionNameValid( collectionName: string, namespaces: string[], @@ -176,73 +131,15 @@ const CollectionDrawerContent: React.FunctionComponent< /> - - - Relationships  - {relationships.length} - - - } - > -
- {!relationships.length ? ( -
- This collection does not have any relationships yet. -
- ) : ( -
    - {relationships.map((r) => { - const relationshipLabel = getDefaultRelationshipName( - r.relationship - ); - - return ( -
  • - - {relationshipLabel} - - { - onEditRelationshipClick(r.id); - }} - > - - - { - onDeleteRelationshipClick(r.id); - }} - > - - -
  • - ); - })} -
- )} -
-
+ { + onCreateNewRelationshipClick(namespace); + }} + onEditRelationshipClick={onEditRelationshipClick} + onDeleteRelationshipClick={onDeleteRelationshipClick} + /> diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx index 2e52603312b..8aed3512d44 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx @@ -12,8 +12,11 @@ import { deleteCollection, deleteRelationship, selectCurrentModelFromState, + type SelectedItems, } from '../../store/diagram'; import { getDefaultRelationshipName } from '../../utils'; +import FieldDrawerContent from './field-drawer-content'; +import type { FieldPath } from '../../services/data-model-storage'; export const DATA_MODELING_DRAWER_ID = 'data-modeling-drawer'; @@ -32,19 +35,17 @@ const drawerTitleTextStyles = css({ const drawerTitleActionGroupStyles = css({}); type DiagramEditorSidePanelProps = { - selectedItems: { - id: string; - type: 'relationship' | 'collection'; - label: string; - } | null; + selectedItems: (SelectedItems & { label: string }) | null; onDeleteCollection: (ns: string) => void; onDeleteRelationship: (rId: string) => void; + onDeleteField: (ns: string, fieldPath: FieldPath) => void; }; function DiagramEditorSidePanel({ selectedItems, onDeleteCollection, onDeleteRelationship, + onDeleteField, }: DiagramEditorSidePanelProps) { const { content, label, actions, handleAction } = useMemo(() => { if (selectedItems?.type === 'collection') { @@ -91,8 +92,31 @@ function DiagramEditorSidePanel({ }; } + if (selectedItems?.type === 'field') { + return { + label: selectedItems.label, + content: ( + + ), + actions: [ + { action: 'delete', label: 'Delete', icon: 'Trash' as const }, + ], + handleAction: (actionName: string) => { + if (actionName === 'delete') { + onDeleteField(selectedItems.namespace, selectedItems.fieldPath); + } + }, + }; + } + return { content: null }; - }, [selectedItems, onDeleteCollection, onDeleteRelationship]); + }, [selectedItems, onDeleteCollection, onDeleteRelationship, onDeleteField]); if (!content) { return null; @@ -184,9 +208,30 @@ export default connect( }, }; } + + if (selected.type === 'field') { + // const collection = model.collections.find((collection) => (collection.ns === selected.namespace)); + // const field = + + // if (!field) { + // // TODO(COMPASS-9680): When the selected field doesn't exist we don't + // // show any selection. + // return { + // selectedItems: null, + // }; + // } + + return { + selectedItems: { + ...selected, + label: selected.fieldPath.join('.'), + }, + }; + } }, { onDeleteCollection: deleteCollection, onDeleteRelationship: deleteRelationship, + onDeleteField: () => {}, // TODO onDeleteField, } )(DiagramEditorSidePanel); 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 1405e5bd6a4..994af47147d 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -1,6 +1,14 @@ import { z } from '@mongodb-js/compass-user-data'; import type { MongoDBJSONSchema } from 'mongodb-schema'; +export const FieldPathSchema = z.array(z.string()); + +export type FieldPath = z.output; + +export const FieldSchema = z.custom(); + +export type FieldSchema = z.output; + export const RelationshipSideSchema = z.object({ ns: z.string().nullable(), cardinality: z.number(), @@ -86,6 +94,44 @@ const EditSchemaVariants = z.discriminatedUnion('type', [ position: z.tuple([z.number(), z.number()]), initialSchema: z.custom(), }), + // Field operations + z.object({ + type: z.literal('RenameField'), + ns: z.string(), + from: FieldPathSchema, + to: FieldPathSchema, + }), + z.object({ + type: z.literal('RemoveField'), + ns: z.string(), + field: FieldPathSchema, + }), + z.object({ + type: z.literal('ChangeFieldType'), + ns: z.string(), + field: FieldPathSchema, + from: FieldSchema, + to: FieldSchema, + }), + z.object({ + type: z.literal('AddField'), + ns: z.string(), + field: FieldPathSchema, + jsonSchema: FieldSchema, + }), + z.object({ + type: z.literal('DuplicateField'), + ns: z.string(), + field: FieldPathSchema, + }), + z.object({ + type: z.literal('MoveField'), + sourceNS: z.string(), + targetNS: z.string(), + targetField: FieldPathSchema, + field: FieldPathSchema, + jsonSchema: z.custom(), + }), ]); export const EditSchema = z.intersection(EditSchemaBase, EditSchemaVariants); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index ddef3e11782..31c0262a46b 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -4,6 +4,7 @@ import { isAction } from './util'; import type { DataModelCollection, EditAction, + FieldPath, Relationship, } from '../services/data-model-storage'; import { @@ -33,10 +34,16 @@ function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { return Array.isArray(arr) && arr.length > 0; } -export type SelectedItems = { - type: 'collection' | 'relationship'; - id: string; -}; +export type SelectedItems = + | { + type: 'collection' | 'relationship'; + id: string; + } + | { + type: 'field'; + namespace: string; + fieldPath: FieldPath; + }; export type DiagramState = | (Omit & { @@ -62,6 +69,7 @@ export enum DiagramActionTypes { REDO_EDIT = 'data-modeling/diagram/REDO_EDIT', COLLECTION_SELECTED = 'data-modeling/diagram/COLLECTION_SELECTED', RELATIONSHIP_SELECTED = 'data-modeling/diagram/RELATIONSHIP_SELECTED', + FIELD_SELECTED = 'data-modeling/diagram/FIELD_SELECTED', DIAGRAM_BACKGROUND_SELECTED = 'data-modeling/diagram/DIAGRAM_BACKGROUND_SELECTED', } @@ -109,6 +117,12 @@ export type RelationSelectedAction = { relationshipId: string; }; +export type FieldSelectedAction = { + type: DiagramActionTypes.FIELD_SELECTED; + namespace: string; + fieldPath: FieldPath; +}; + export type DiagramBackgroundSelectedAction = { type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED; }; @@ -123,6 +137,7 @@ export type DiagramActions = | RedoEditAction | CollectionSelectedAction | RelationSelectedAction + | FieldSelectedAction | DiagramBackgroundSelectedAction; const INITIAL_STATE: DiagramState = null; @@ -280,6 +295,16 @@ export const diagramReducer: Reducer = ( }, }; } + if (isAction(action, DiagramActionTypes.FIELD_SELECTED)) { + return { + ...state, + selectedItems: { + type: 'field', + namespace: action.namespace, + fieldPath: action.fieldPath, + }, + }; + } if (isAction(action, DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED)) { return { ...state, @@ -378,6 +403,17 @@ export function selectRelationship( }; } +export function selectField( + namespace: string, + fieldPath: FieldPath +): FieldSelectedAction { + return { + type: DiagramActionTypes.FIELD_SELECTED, + namespace, + fieldPath, + }; +} + export function selectBackground(): DiagramBackgroundSelectedAction { return { type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED, 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 30dbf336402..956f0403d96 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -6,6 +6,7 @@ import type { MongoDBJSONSchema } from 'mongodb-schema'; import type { SelectedItems } from '../store/diagram'; import type { DataModelCollection, + FieldPath, Relationship, } from '../services/data-model-storage'; @@ -74,7 +75,8 @@ export const getSelectedFields = ( export const getFieldsFromSchema = ( jsonSchema: MongoDBJSONSchema, highlightedFields: string[][] = [], - depth = 0 + depth = 0, + parentFieldPath: FieldPath = [] ): NodeProps['fields'] => { if (!jsonSchema || !jsonSchema.properties) { return []; @@ -109,11 +111,15 @@ export const getFieldsFromSchema = ( } } + const newFieldPath = [...parentFieldPath, name]; + fields.push({ name, + id: newFieldPath, type: getFieldTypeDisplay(types.flat()), depth: depth, glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [], + selectable: true, variant: highlightedFields.length && highlightedFields.some( @@ -132,7 +138,8 @@ export const getFieldsFromSchema = ( highlightedFields .filter((field) => field[0] === name) .map((field) => field.slice(1)), - depth + 1 + depth + 1, + newFieldPath ) ), ]; From 871dc547e9a9a16732c88fcd86ee200f5376e248 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Wed, 20 Aug 2025 15:35:34 +0200 Subject: [PATCH 02/19] finish highlighting, relationship management, add file --- .../src/components/diagram-editor.tsx | 23 ++- .../drawer/collection-drawer-content.tsx | 9 +- .../drawer/diagram-editor-side-panel.tsx | 9 +- .../drawer/field-drawer-content.tsx | 192 ++++++++++++++++++ .../drawer/relationships-section.tsx | 128 ++++++++++++ .../src/store/diagram.ts | 19 +- .../src/utils/nodes-and-edges.tsx | 54 +++-- 7 files changed, 400 insertions(+), 34 deletions(-) create mode 100644 packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx create mode 100644 packages/compass-data-modeling/src/components/drawer/relationships-section.tsx diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index a82ab57fa5c..5b50fe9f42d 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -43,7 +43,7 @@ import ExportDiagramModal from './export-diagram-modal'; import { DATA_MODELING_DRAWER_ID } from './drawer/diagram-editor-side-panel'; import { collectionToDiagramNode, - getSelectedFields, + getHighlightedFields, relationshipToDiagramEdge, } from '../utils/nodes-and-edges'; @@ -117,7 +117,13 @@ const DiagramContent: React.FunctionComponent<{ onFieldSelect: (namespace: string, fieldPath: FieldPath) => void; onDiagramBackgroundClicked: () => void; selectedItems: SelectedItems; - onCreateNewRelationship: (source: string, target: string) => void; + onCreateNewRelationship: ({ + localNamespace, + foreignNamespace, + }: { + localNamespace: string; + foreignNamespace: string; + }) => void; onRelationshipDrawn: () => void; }> = ({ diagramLabel, @@ -156,7 +162,7 @@ const DiagramContent: React.FunctionComponent<{ }, [model?.relationships, selectedItems]); const nodes = useMemo(() => { - const selectedFields = getSelectedFields( + const highlightedFields = getHighlightedFields( selectedItems, model?.relationships ); @@ -166,7 +172,11 @@ const DiagramContent: React.FunctionComponent<{ selectedItems.type === 'collection' && selectedItems.id === coll.ns; return collectionToDiagramNode(coll, { - selectedFields, + highlightedFields, + selectedField: + selectedItems?.type === 'field' && selectedItems.namespace === coll.ns + ? selectedItems.fieldPath + : undefined, selected, isInRelationshipDrawingMode, }); @@ -222,7 +232,10 @@ const DiagramContent: React.FunctionComponent<{ const handleNodesConnect = useCallback( (source: string, target: string) => { - onCreateNewRelationship(source, target); + onCreateNewRelationship({ + localNamespace: source, + foreignNamespace: target, + }); onRelationshipDrawn(); }, [onRelationshipDrawn, onCreateNewRelationship] diff --git a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx index 3b6661c0ce2..c2e567ce6a2 100644 --- a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx @@ -26,7 +26,11 @@ type CollectionDrawerContentProps = { note?: string; relationships: Relationship[]; isDraftCollection?: boolean; - onCreateNewRelationshipClick: (namespace: string) => void; + onCreateNewRelationshipClick: ({ + localNamespace, + }: { + localNamespace: string; + }) => void; onEditRelationshipClick: (rId: string) => void; onDeleteRelationshipClick: (rId: string) => void; onNoteChange: (namespace: string, note: string) => void; @@ -133,9 +137,10 @@ const CollectionDrawerContent: React.FunctionComponent< { - onCreateNewRelationshipClick(namespace); + onCreateNewRelationshipClick({ localNamespace: namespace }); }} onEditRelationshipClick={onEditRelationshipClick} onDeleteRelationshipClick={onDeleteRelationshipClick} diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx index 8aed3512d44..13423ef4369 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { connect } from 'react-redux'; +import toNS from 'mongodb-ns'; import type { DataModelingState } from '../../store/reducer'; import { css, @@ -41,6 +42,8 @@ type DiagramEditorSidePanelProps = { onDeleteField: (ns: string, fieldPath: FieldPath) => void; }; +const getCollection = (namespace: string) => toNS(namespace).collection; + function DiagramEditorSidePanel({ selectedItems, onDeleteCollection, @@ -183,7 +186,7 @@ export default connect( return { selectedItems: { ...selected, - label: selected.id, + label: getCollection(selected.id), }, }; } @@ -224,7 +227,9 @@ export default connect( return { selectedItems: { ...selected, - label: selected.fieldPath.join('.'), + label: `${getCollection( + selected.namespace + )}.${selected.fieldPath.join('.')}`, }, }; } diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx new file mode 100644 index 00000000000..920045987db --- /dev/null +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -0,0 +1,192 @@ +import React, { useMemo } from 'react'; +import { connect } from 'react-redux'; +import type { + FieldPath, + FieldSchema, + Relationship, +} from '../../services/data-model-storage'; +import { TextInput } from '@mongodb-js/compass-components'; +import toNS from 'mongodb-ns'; +import { + createNewRelationship, + deleteRelationship, + selectCurrentModelFromState, + selectRelationship, +} from '../../store/diagram'; +import type { DataModelingState } from '../../store/reducer'; +import { + DMDrawerSection, + DMFormFieldContainer, +} from './drawer-section-components'; +import { useChangeOnBlur } from './use-change-on-blur'; +import { RelationshipsSection } from './relationships-section'; + +type FieldDrawerContentProps = { + namespace: string; + fieldPath: FieldPath; + fieldPaths: FieldPath[]; + jsonSchema: FieldSchema; + relationships: Relationship[]; + onCreateNewRelationshipClick: ({ + localNamespace, + localFields, + }: { + localNamespace: string; + localFields: FieldPath; + }) => void; + onEditRelationshipClick: (rId: string) => void; + onDeleteRelationshipClick: (rId: string) => void; + onRenameField: ( + namespace: string, + fromFieldPath: FieldPath, + toFieldPath: FieldPath + ) => void; + onChangeFieldType: ( + namespace: string, + fieldPath: FieldPath, + fromBsonType: string, + toBsonType: string + ) => void; +}; + +export function getIsFieldNameValid( + currentFieldPath: FieldPath, + existingFields: FieldPath[], + newName: string +): { + isValid: boolean; + errorMessage?: string; +} { + const trimmedName = newName.trim(); + if (!trimmedName.length) { + return { + isValid: false, + errorMessage: 'Field name cannot be empty.', + }; + } + + const fieldsNamesWithoutCurrent = existingFields + .filter( + (fieldPath) => + JSON.stringify(fieldPath) !== JSON.stringify(currentFieldPath) + ) + .map((fieldPath) => fieldPath[fieldPath.length - 1]); + + const isDuplicate = fieldsNamesWithoutCurrent.some( + (fieldName) => fieldName === trimmedName + ); + + return { + isValid: !isDuplicate, + errorMessage: isDuplicate ? 'Field already exists.' : undefined, + }; +} + +const FieldDrawerContent: React.FunctionComponent = ({ + namespace, + fieldPath, + fieldPaths, + jsonSchema, + relationships, + onCreateNewRelationshipClick, + onEditRelationshipClick, + onDeleteRelationshipClick, + onRenameField, + onChangeFieldType, +}) => { + const { value: fieldName, ...nameInputProps } = useChangeOnBlur( + fieldPath[fieldPath.length - 1], + (fieldName) => { + const trimmedName = fieldName.trim(); + if (trimmedName === fieldName) { + return; + } + if (!isFieldNameValid) { + return; + } + onRenameField(namespace, fieldPath, [ + ...fieldPath.slice(0, fieldPath.length - 1), + trimmedName, + ]); + } + ); + + const { isValid: isFieldNameValid, errorMessage: fieldNameEditErrorMessage } = + useMemo( + () => getIsFieldNameValid(fieldPath, fieldPaths, fieldName), + [fieldPath, fieldPaths, fieldName] + ); + + return ( + <> + + + + + + + { + const labelField = + local.ns === namespace && + JSON.stringify(local.fields) === JSON.stringify(fieldPath) + ? foreign + : local; + return [ + labelField.ns ? toNS(labelField.ns).collection : '', + labelField.fields?.join('.'), + ].join('.'); + }} + onCreateNewRelationshipClick={() => { + onCreateNewRelationshipClick({ + localNamespace: namespace, + localFields: fieldPath, + }); + }} + onEditRelationshipClick={onEditRelationshipClick} + onDeleteRelationshipClick={onDeleteRelationshipClick} + /> + + ); +}; + +export default connect( + ( + state: DataModelingState, + ownProps: { namespace: string; fieldPath: FieldPath } + ) => { + const model = selectCurrentModelFromState(state); + return { + jsonSchema: {}, // TODO get field schema + fieldPaths: [], // TODO get field paths + relationships: model.relationships.filter((r) => { + const [local, foreign] = r.relationship; + return ( + (local.ns === ownProps.namespace && + JSON.stringify(local.fields) === + JSON.stringify(ownProps.fieldPath)) || + (foreign.ns === ownProps.namespace && + JSON.stringify(foreign.fields) === + JSON.stringify(ownProps.fieldPath)) + ); + }), + }; + }, + { + onCreateNewRelationshipClick: createNewRelationship, + onEditRelationshipClick: selectRelationship, + onDeleteRelationshipClick: deleteRelationship, + onRenameField: () => {}, // TODO: renameField, + onChangeFieldType: () => {}, // TODO: updateFieldSchema, + } +)(FieldDrawerContent); diff --git a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx new file mode 100644 index 00000000000..3d578aeefd3 --- /dev/null +++ b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { DMDrawerSection } from './drawer-section-components'; +import { + Badge, + Button, + css, + Icon, + IconButton, + palette, + spacing, +} from '@mongodb-js/compass-components'; +import type { Relationship } from '../../services/data-model-storage'; + +const titleBtnStyles = css({ + marginLeft: 'auto', + maxHeight: 20, // To match accordion line height +}); + +const emptyRelationshipMessageStyles = css({ + color: palette.gray.dark1, +}); + +const relationshipsListStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[200], +}); + +const relationshipItemStyles = css({ + display: 'flex', + alignItems: 'center', +}); + +const relationshipNameStyles = css({ + flexGrow: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + paddingRight: spacing[200], +}); + +const relationshipContentStyles = css({ + marginTop: spacing[400], +}); + +type RelationshipsSectionProps = { + relationships: Relationship[]; + emptyMessage: string; + getRelationshipLabel: (relationship: Relationship['relationship']) => string; + onCreateNewRelationshipClick: () => void; + onEditRelationshipClick: (rId: string) => void; + onDeleteRelationshipClick: (rId: string) => void; +}; + +export const RelationshipsSection: React.FunctionComponent< + RelationshipsSectionProps +> = ({ + relationships, + emptyMessage, + getRelationshipLabel, + onCreateNewRelationshipClick, + onEditRelationshipClick, + onDeleteRelationshipClick, +}) => { + return ( + + Relationships  + {relationships.length} + + + } + > +
+ {!relationships.length ? ( +
{emptyMessage}
+ ) : ( +
    + {relationships.map((r) => { + const relationshipLabel = getRelationshipLabel(r.relationship); + + return ( +
  • + + {relationshipLabel} + + { + onEditRelationshipClick(r.id); + }} + > + + + { + onDeleteRelationshipClick(r.id); + }} + > + + +
  • + ); + })} +
+ )} +
+
+ ); +}; diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 31c0262a46b..fefab230d8c 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -420,10 +420,17 @@ export function selectBackground(): DiagramBackgroundSelectedAction { }; } -export function createNewRelationship( - localNamespace: string, - foreignNamespace: string | null = null -): DataModelingThunkAction { +export function createNewRelationship({ + localNamespace, + foreignNamespace = null, + localFields = null, + foreignFields = null, +}: { + localNamespace: string; + foreignNamespace?: string | null; + localFields?: FieldPath | null; + foreignFields?: FieldPath | null; +}): DataModelingThunkAction { return (dispatch, getState, { track }) => { const relationshipId = new UUID().toString(); const currentNumberOfRelationships = getCurrentNumberOfRelationships( @@ -435,8 +442,8 @@ export function createNewRelationship( relationship: { id: relationshipId, relationship: [ - { ns: localNamespace, cardinality: 1, fields: null }, - { ns: foreignNamespace, cardinality: 1, fields: null }, + { ns: localNamespace, cardinality: 1, fields: localFields }, + { ns: foreignNamespace, cardinality: 1, fields: foreignFields }, ], isInferred: false, }, 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 956f0403d96..9541fd36d36 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -52,7 +52,7 @@ function getFieldTypeDisplay(bsonTypes: string[]) { ); } -export const getSelectedFields = ( +export const getHighlightedFields = ( selectedItems: SelectedItems | null, relationships?: Relationship[] ): Record => { @@ -72,12 +72,19 @@ export const getSelectedFields = ( return selection; }; -export const getFieldsFromSchema = ( - jsonSchema: MongoDBJSONSchema, - highlightedFields: string[][] = [], +export const getFieldsFromSchema = ({ + jsonSchema, + highlightedFields = [], + selectedField, depth = 0, - parentFieldPath: FieldPath = [] -): NodeProps['fields'] => { + parentFieldPath = [], +}: { + jsonSchema: MongoDBJSONSchema; + highlightedFields?: FieldPath[]; + selectedField?: FieldPath; + depth?: number; + parentFieldPath?: FieldPath; +}): NodeProps['fields'] => { if (!jsonSchema || !jsonSchema.properties) { return []; } @@ -120,6 +127,7 @@ export const getFieldsFromSchema = ( depth: depth, glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [], selectable: true, + selected: selectedField?.length === 1 && selectedField[0] === name, variant: highlightedFields.length && highlightedFields.some( @@ -133,14 +141,18 @@ export const getFieldsFromSchema = ( fields = [ ...fields, ...children.flat().flatMap((child) => - getFieldsFromSchema( - child, - highlightedFields + getFieldsFromSchema({ + jsonSchema: child, + highlightedFields: highlightedFields .filter((field) => field[0] === name) .map((field) => field.slice(1)), - depth + 1, - newFieldPath - ) + selectedField: + selectedField && selectedField[0] === name + ? selectedField.slice(1) + : undefined, + depth: depth + 1, + parentFieldPath: newFieldPath, + }) ), ]; } @@ -152,16 +164,19 @@ export const getFieldsFromSchema = ( export function collectionToDiagramNode( coll: Pick, options: { - selectedFields?: Record; + highlightedFields?: Record; + selectedField?: FieldPath; selected?: boolean; isInRelationshipDrawingMode?: boolean; } = {} ): NodeProps { const { - selectedFields = {}, + highlightedFields = {}, + selectedField, selected = false, isInRelationshipDrawingMode = false, } = options; + console.log('collection level', { selectedField }); return { id: coll.ns, @@ -171,11 +186,12 @@ export function collectionToDiagramNode( y: coll.displayPosition[1], }, title: toNS(coll.ns).collection, - fields: getFieldsFromSchema( - coll.jsonSchema, - selectedFields[coll.ns] ?? undefined, - 0 - ), + fields: getFieldsFromSchema({ + jsonSchema: coll.jsonSchema, + highlightedFields: highlightedFields[coll.ns] ?? undefined, + selectedField, + depth: 0, + }), selected, connectable: isInRelationshipDrawingMode, draggable: !isInRelationshipDrawingMode, From 055ba5576b1e20fccd3829828048edf7538e7928 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Thu, 21 Aug 2025 11:16:37 +0200 Subject: [PATCH 03/19] refactor schema traversal with visitor --- .../src/utils/nodes-and-edges.spec.tsx | 327 +++++++++++++----- .../src/utils/nodes-and-edges.tsx | 114 +++--- 2 files changed, 312 insertions(+), 129 deletions(-) diff --git a/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx b/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx index 0d1b98e5c27..eef9c6b1789 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx @@ -8,7 +8,7 @@ import { } from '@mongodb-js/testing-library-compass'; import { getFieldsFromSchema } from '../utils/nodes-and-edges'; -describe('getFieldsFromSchema', function () { +describe.only('getFieldsFromSchema', function () { const validateMixedType = async ( type: React.ReactNode, expectedTooltip: RegExp @@ -25,49 +25,68 @@ describe('getFieldsFromSchema', function () { describe('flat schema', function () { it('return empty array for empty schema', function () { - const result = getFieldsFromSchema({}); + const result = getFieldsFromSchema({ jsonSchema: {} }); expect(result).to.deep.equal([]); }); it('returns fields for a simple schema', function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - name: { bsonType: 'string' }, - age: { bsonType: 'int' }, + jsonSchema: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + age: { bsonType: 'int' }, + }, }, }); expect(result).to.deep.equal([ { name: 'name', + id: ['name'], type: 'string', depth: 0, glyphs: [], + selectable: true, + selected: false, + variant: undefined, + }, + { + name: 'age', + id: ['age'], + type: 'int', + depth: 0, + glyphs: [], + selectable: true, + selected: false, variant: undefined, }, - { name: 'age', type: 'int', depth: 0, glyphs: [], variant: undefined }, ]); }); it('returns mixed fields with tooltip on hover', async function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - age: { bsonType: ['int', 'string'] }, + jsonSchema: { + bsonType: 'object', + properties: { + age: { bsonType: ['int', 'string'] }, + }, }, }); expect(result[0]).to.deep.include({ name: 'age', + id: ['age'], depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }); await validateMixedType(result[0].type, /int, string/); }); it('highlights the correct field', function () { - const result = getFieldsFromSchema( - { + const result = getFieldsFromSchema({ + jsonSchema: { bsonType: 'object', properties: { name: { bsonType: 'string' }, @@ -75,30 +94,45 @@ describe('getFieldsFromSchema', function () { profession: { bsonType: 'string' }, }, }, - [['age']] - ); + highlightedFields: [['age']], + }); expect(result).to.deep.equal([ { name: 'name', + id: ['name'], type: 'string', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, - { name: 'age', type: 'int', depth: 0, glyphs: [], variant: 'preview' }, + { + name: 'age', + id: ['age'], + type: 'int', + depth: 0, + glyphs: [], + selectable: true, + selected: false, + variant: 'preview', + }, { name: 'profession', + id: ['profession'], type: 'string', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, ]); }); it('highlights multiple fields', function () { - const result = getFieldsFromSchema( - { + const result = getFieldsFromSchema({ + jsonSchema: { bsonType: 'object', properties: { name: { bsonType: 'string' }, @@ -106,22 +140,37 @@ describe('getFieldsFromSchema', function () { profession: { bsonType: 'string' }, }, }, - [['age'], ['profession']] - ); + highlightedFields: [['age'], ['profession']], + }); expect(result).to.deep.equal([ { name: 'name', + id: ['name'], type: 'string', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, - { name: 'age', type: 'int', depth: 0, glyphs: [], variant: 'preview' }, + { + name: 'age', + id: ['age'], + type: 'int', + depth: 0, + glyphs: [], + selectable: true, + selected: false, + variant: 'preview', + }, { name: 'profession', + id: ['profession'], type: 'string', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: 'preview', }, ]); @@ -131,17 +180,19 @@ describe('getFieldsFromSchema', function () { describe('nested schema', function () { it('returns fields for a nested schema', function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - person: { - bsonType: 'object', - properties: { - name: { bsonType: 'string' }, - address: { - bsonType: 'object', - properties: { - street: { bsonType: 'string' }, - city: { bsonType: 'string' }, + jsonSchema: { + bsonType: 'object', + properties: { + person: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + address: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, }, }, }, @@ -151,45 +202,60 @@ describe('getFieldsFromSchema', function () { expect(result).to.deep.equal([ { name: 'person', + id: ['person'], type: 'object', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'name', + id: ['person', 'name'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'address', + id: ['person', 'address'], type: 'object', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'street', + id: ['person', 'address', 'street'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'city', + id: ['person', 'address', 'city'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, ]); }); it('highlights a field for a nested schema', function () { - const result = getFieldsFromSchema( - { + const result = getFieldsFromSchema({ + jsonSchema: { bsonType: 'object', properties: { person: { @@ -207,50 +273,65 @@ describe('getFieldsFromSchema', function () { }, }, }, - [['person', 'address', 'street']] - ); + highlightedFields: [['person', 'address', 'street']], + }); expect(result).to.deep.equal([ { name: 'person', + id: ['person'], type: 'object', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'name', + id: ['person', 'name'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'address', + id: ['person', 'address'], type: 'object', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'street', + id: ['person', 'address', 'street'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: 'preview', }, { name: 'city', + id: ['person', 'address', 'city'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, ]); }); it('highlights multiple fields for a nested schema', function () { - const result = getFieldsFromSchema( - { + const result = getFieldsFromSchema({ + jsonSchema: { bsonType: 'object', properties: { person: { @@ -275,66 +356,90 @@ describe('getFieldsFromSchema', function () { }, }, }, - [ + highlightedFields: [ ['person', 'address', 'street'], ['person', 'billingAddress', 'city'], - ] - ); + ], + }); expect(result).to.deep.equal([ { name: 'person', + id: ['person'], type: 'object', depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'name', + id: ['person', 'name'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'address', + id: ['person', 'address'], type: 'object', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'street', + id: ['person', 'address', 'street'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: 'preview', }, { name: 'city', + id: ['person', 'address', 'city'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'billingAddress', + id: ['person', 'billingAddress'], type: 'object', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'street', + id: ['person', 'billingAddress', 'street'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'city', + id: ['person', 'billingAddress', 'city'], type: 'string', depth: 2, glyphs: [], + selectable: true, + selected: false, variant: 'preview', }, ]); @@ -342,49 +447,77 @@ describe('getFieldsFromSchema', function () { it('returns [] for array', function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - tags: { - bsonType: 'array', - items: { bsonType: 'string' }, + jsonSchema: { + bsonType: 'object', + properties: { + tags: { + bsonType: 'array', + items: { bsonType: 'string' }, + }, }, }, }); expect(result).to.deep.equal([ - { name: 'tags', type: '[]', depth: 0, glyphs: [], variant: undefined }, + { + name: 'tags', + id: ['tags'], + type: '[]', + depth: 0, + glyphs: [], + selectable: true, + selected: false, + variant: undefined, + }, ]); }); it('returns fields for an array of objects', function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - todos: { - bsonType: 'array', - items: { - bsonType: 'object', - properties: { - title: { bsonType: 'string' }, - completed: { bsonType: 'boolean' }, + jsonSchema: { + bsonType: 'object', + properties: { + todos: { + bsonType: 'array', + items: { + bsonType: 'object', + properties: { + title: { bsonType: 'string' }, + completed: { bsonType: 'boolean' }, + }, }, }, }, }, }); expect(result).to.deep.equal([ - { name: 'todos', type: '[]', depth: 0, glyphs: [], variant: undefined }, + { + name: 'todos', + id: ['todos'], + type: '[]', + depth: 0, + glyphs: [], + selectable: true, + selected: false, + variant: undefined, + }, { name: 'title', + id: ['todos', 'title'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'completed', + id: ['todos', 'completed'], type: 'boolean', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, ]); @@ -392,81 +525,109 @@ describe('getFieldsFromSchema', function () { it('returns fields for a mixed schema with objects', async function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - name: { - anyOf: [ - { bsonType: 'string' }, - { - bsonType: 'object', - properties: { - first: { bsonType: 'string' }, - last: { bsonType: 'string' }, + jsonSchema: { + bsonType: 'object', + properties: { + name: { + anyOf: [ + { bsonType: 'string' }, + { + bsonType: 'object', + properties: { + first: { bsonType: 'string' }, + last: { bsonType: 'string' }, + }, }, - }, - ], + ], + }, }, }, }); expect(result).to.have.lengthOf(3); expect(result[0]).to.deep.include({ name: 'name', + id: ['name'], depth: 0, glyphs: [], + selectable: true, + selected: false, variant: undefined, }); await validateMixedType(result[0].type, /string, object/); expect(result[1]).to.deep.equal({ name: 'first', + id: ['name', 'first'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }); expect(result[2]).to.deep.equal({ name: 'last', + id: ['name', 'last'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }); }); it('returns fields for an array of mixed (including objects)', function () { const result = getFieldsFromSchema({ - bsonType: 'object', - properties: { - todos: { - bsonType: 'array', - items: { - anyOf: [ - { - bsonType: 'object', - properties: { - title: { bsonType: 'string' }, - completed: { bsonType: 'boolean' }, + jsonSchema: { + bsonType: 'object', + properties: { + todos: { + bsonType: 'array', + items: { + anyOf: [ + { + bsonType: 'object', + properties: { + title: { bsonType: 'string' }, + completed: { bsonType: 'boolean' }, + }, }, - }, - { bsonType: 'string' }, - ], + { bsonType: 'string' }, + ], + }, }, }, }, }); expect(result).to.deep.equal([ - { name: 'todos', type: '[]', depth: 0, glyphs: [], variant: undefined }, + { + name: 'todos', + id: ['todos'], + type: '[]', + depth: 0, + glyphs: [], + selectable: true, + selected: false, + variant: undefined, + }, { name: 'title', + id: ['todos', 'title'], type: 'string', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, { name: 'completed', + id: ['todos', 'completed'], type: 'boolean', depth: 1, glyphs: [], + selectable: true, + selected: false, variant: undefined, }, ]); 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 9541fd36d36..d018e98d7db 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -72,23 +72,29 @@ export const getHighlightedFields = ( return selection; }; -export const getFieldsFromSchema = ({ +// TODO: add support for update +// renaming fields, deleting fields, adding fields... +// or maybe we can have two traverses? one for parsing, one for updating? +export const traverseSchema = ({ jsonSchema, - highlightedFields = [], - selectedField, - depth = 0, parentFieldPath = [], + visitor, }: { jsonSchema: MongoDBJSONSchema; - highlightedFields?: FieldPath[]; - selectedField?: FieldPath; - depth?: number; + visitor: ({ + fieldPath, + fieldName, + fieldTypes, + }: { + fieldPath: FieldPath; + fieldName: string; + fieldTypes: string[]; + }) => void; parentFieldPath?: FieldPath; -}): NodeProps['fields'] => { +}): void => { if (!jsonSchema || !jsonSchema.properties) { - return []; + return; } - let fields: NodeProps['fields'] = []; for (const [name, field] of Object.entries(jsonSchema.properties)) { // field has types, properties and (optional) children // types are either direct, or from anyof @@ -120,43 +126,61 @@ export const getFieldsFromSchema = ({ const newFieldPath = [...parentFieldPath, name]; - fields.push({ - name, - id: newFieldPath, - type: getFieldTypeDisplay(types.flat()), - depth: depth, - glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [], - selectable: true, - selected: selectedField?.length === 1 && selectedField[0] === name, - variant: - highlightedFields.length && - highlightedFields.some( - (field) => field.length === 1 && field[0] === name - ) - ? 'preview' - : undefined, + visitor({ + fieldPath: newFieldPath, + fieldName: name, + fieldTypes: types.flat(), }); - if (children.length > 0) { - fields = [ - ...fields, - ...children.flat().flatMap((child) => - getFieldsFromSchema({ - jsonSchema: child, - highlightedFields: highlightedFields - .filter((field) => field[0] === name) - .map((field) => field.slice(1)), - selectedField: - selectedField && selectedField[0] === name - ? selectedField.slice(1) - : undefined, - depth: depth + 1, - parentFieldPath: newFieldPath, - }) - ), - ]; - } + children.flat().forEach((child) => + traverseSchema({ + jsonSchema: child, + visitor, + parentFieldPath: newFieldPath, + }) + ); + } +}; + +export const getFieldsFromSchema = ({ + jsonSchema, + highlightedFields = [], + selectedField, +}: { + jsonSchema: MongoDBJSONSchema; + highlightedFields?: FieldPath[]; + selectedField?: FieldPath; +}): NodeProps['fields'] => { + if (!jsonSchema || !jsonSchema.properties) { + return []; } + const fields: NodeProps['fields'] = []; + + traverseSchema({ + jsonSchema, + visitor: ({ fieldPath, fieldName, fieldTypes }) => { + fields.push({ + name: fieldName, + id: fieldPath, + type: getFieldTypeDisplay(fieldTypes), + depth: fieldPath.length - 1, + glyphs: + fieldTypes.length === 1 && fieldTypes[0] === 'objectId' + ? ['key'] + : [], + selectable: true, + selected: JSON.stringify(fieldPath) === JSON.stringify(selectedField), + variant: + highlightedFields.length && + highlightedFields.some( + (highlightedField) => + JSON.stringify(fieldPath) === JSON.stringify(highlightedField) + ) + ? 'preview' + : undefined, + }); + }, + }); return fields; }; @@ -176,7 +200,6 @@ export function collectionToDiagramNode( selected = false, isInRelationshipDrawingMode = false, } = options; - console.log('collection level', { selectedField }); return { id: coll.ns, @@ -190,7 +213,6 @@ export function collectionToDiagramNode( jsonSchema: coll.jsonSchema, highlightedFields: highlightedFields[coll.ns] ?? undefined, selectedField, - depth: 0, }), selected, connectable: isInRelationshipDrawingMode, From 2eb17a555bd061d457828da5a329398687b3da06 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Thu, 21 Aug 2025 13:20:12 +0200 Subject: [PATCH 04/19] . --- .../drawer/field-drawer-content.tsx | 11 ++- .../src/store/diagram.ts | 97 ++++++++++++++----- .../src/utils/nodes-and-edges.spec.tsx | 2 +- .../src/utils/nodes-and-edges.tsx | 7 +- 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 920045987db..893c4c04fb1 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -10,6 +10,7 @@ import toNS from 'mongodb-ns'; import { createNewRelationship, deleteRelationship, + renameField, selectCurrentModelFromState, selectRelationship, } from '../../store/diagram'; @@ -98,10 +99,15 @@ const FieldDrawerContent: React.FunctionComponent = ({ fieldPath[fieldPath.length - 1], (fieldName) => { const trimmedName = fieldName.trim(); - if (trimmedName === fieldName) { + console.log( + `[Rename] ${fieldPath[fieldPath.length - 1]} -> ${trimmedName}` + ); + if (trimmedName === fieldPath[fieldPath.length - 1]) { + console.log('[Rename] No change in field name, skipping'); return; } if (!isFieldNameValid) { + console.log('[Rename] Invalid field name, skipping'); return; } onRenameField(namespace, fieldPath, [ @@ -123,6 +129,7 @@ const FieldDrawerContent: React.FunctionComponent = ({ {}, // TODO: renameField, + onRenameField: renameField, onChangeFieldType: () => {}, // TODO: updateFieldSchema, } )(FieldDrawerContent); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index fefab230d8c..4cc1a4aaf79 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -29,6 +29,7 @@ import type { MongoDBJSONSchema } from 'mongodb-schema'; import { getCoordinatesForNewNode } from '@mongodb-js/diagramming'; import { collectionToDiagramNode } from '../utils/nodes-and-edges'; import toNS from 'mongodb-ns'; +import { traverseSchema } from '../utils/nodes-and-edges'; function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { return Array.isArray(arr) && arr.length > 0; @@ -509,6 +510,29 @@ export function renameCollection( }; } +export function renameField( + ns: string, + from: FieldPath, + to: FieldPath +): DataModelingThunkAction< + void, + ApplyEditAction | ApplyEditFailedAction | CollectionSelectedAction +> { + return (dispatch) => { + const edit: Omit< + Extract, + 'id' | 'timestamp' + > = { + type: 'RenameField', + ns, + from, + to, + }; + + dispatch(applyEdit(edit)); + }; +} + export function applyEdit( rawEdit: EditAction ): DataModelingThunkAction { @@ -835,6 +859,44 @@ function _applyEdit(edit: Edit, model?: StaticModel): StaticModel { })), }; } + case 'RenameField': { + return { + ...model, + // Update relationships to point to the renamed field. + relationships: model.relationships.map((relationship) => { + const [local, foreign] = relationship.relationship; + + return { + ...relationship, + relationship: [ + { + ...local, + fields: + local.ns === edit.ns && + JSON.stringify(local.fields) === JSON.stringify(edit.from) + ? edit.to + : local.fields, + }, + { + ...foreign, + fields: + local.ns === edit.ns && + JSON.stringify(local.fields) === JSON.stringify(edit.from) + ? edit.to + : local.fields, + }, + ], + }; + }), + collections: model.collections.map((collection) => ({ + ...collection, + // TODO: Rename the field. + // jsonSchema: collection.ns !== edit.ns + // ? collection.jsonSchema + // : renameFieldInSchema(collection.jsonSchema, edit.from, edit.to) + })), + }; + } case 'UpdateCollectionNote': { return { ...model, @@ -919,31 +981,14 @@ export const selectCurrentModelFromState = (state: DataModelingState) => { return selectCurrentModel(selectCurrentDiagramFromState(state).edits); }; -function extractFields( - parentSchema: MongoDBJSONSchema, - parentKey?: string[], - fields: string[][] = [] -) { - if ('anyOf' in parentSchema && parentSchema.anyOf) { - for (const schema of parentSchema.anyOf) { - extractFields(schema, parentKey, fields); - } - } - if ('items' in parentSchema && parentSchema.items) { - const items = Array.isArray(parentSchema.items) - ? parentSchema.items - : [parentSchema.items]; - for (const schema of items) { - extractFields(schema, parentKey, fields); - } - } - if ('properties' in parentSchema && parentSchema.properties) { - for (const [key, value] of Object.entries(parentSchema.properties)) { - const fullKey = parentKey ? [...parentKey, key] : [key]; - fields.push(fullKey); - extractFields(value, fullKey, fields); - } - } +function extractFieldsFromSchema(parentSchema: MongoDBJSONSchema): FieldPath[] { + const fields: FieldPath[] = []; + traverseSchema({ + jsonSchema: parentSchema, + visitor: ({ fieldPath }) => { + fields.push(fieldPath); + }, + }); return fields; } @@ -953,7 +998,7 @@ function getFieldsForCurrentModel( const model = selectCurrentModel(edits); const fields = Object.fromEntries( model.collections.map((collection) => { - return [collection.ns, extractFields(collection.jsonSchema)]; + return [collection.ns, extractFieldsFromSchema(collection.jsonSchema)]; }) ); return fields; diff --git a/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx b/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx index eef9c6b1789..63559d867e3 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx @@ -8,7 +8,7 @@ import { } from '@mongodb-js/testing-library-compass'; import { getFieldsFromSchema } from '../utils/nodes-and-edges'; -describe.only('getFieldsFromSchema', function () { +describe('getFieldsFromSchema', function () { const validateMixedType = async ( type: React.ReactNode, expectedTooltip: RegExp 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 d018e98d7db..c5ccf6023d4 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -83,11 +83,9 @@ export const traverseSchema = ({ jsonSchema: MongoDBJSONSchema; visitor: ({ fieldPath, - fieldName, fieldTypes, }: { fieldPath: FieldPath; - fieldName: string; fieldTypes: string[]; }) => void; parentFieldPath?: FieldPath; @@ -128,7 +126,6 @@ export const traverseSchema = ({ visitor({ fieldPath: newFieldPath, - fieldName: name, fieldTypes: types.flat(), }); @@ -158,9 +155,9 @@ export const getFieldsFromSchema = ({ traverseSchema({ jsonSchema, - visitor: ({ fieldPath, fieldName, fieldTypes }) => { + visitor: ({ fieldPath, fieldTypes }) => { fields.push({ - name: fieldName, + name: fieldPath[fieldPath.length - 1], id: fieldPath, type: getFieldTypeDisplay(fieldTypes), depth: fieldPath.length - 1, From 91da9b18e01e1f2ce96a30ad07fbf01bafd17a7f Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Thu, 21 Aug 2025 18:05:03 +0200 Subject: [PATCH 05/19] wip --- .../drawer/field-drawer-content.tsx | 5 -- .../src/store/diagram.ts | 2 +- .../src/utils/nodes-and-edges.tsx | 68 +------------------ 3 files changed, 2 insertions(+), 73 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 893c4c04fb1..dbdcc45bec0 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -99,15 +99,10 @@ const FieldDrawerContent: React.FunctionComponent = ({ fieldPath[fieldPath.length - 1], (fieldName) => { const trimmedName = fieldName.trim(); - console.log( - `[Rename] ${fieldPath[fieldPath.length - 1]} -> ${trimmedName}` - ); if (trimmedName === fieldPath[fieldPath.length - 1]) { - console.log('[Rename] No change in field name, skipping'); return; } if (!isFieldNameValid) { - console.log('[Rename] Invalid field name, skipping'); return; } onRenameField(namespace, fieldPath, [ diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 4cc1a4aaf79..9afd8d5b9e7 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -29,7 +29,7 @@ import type { MongoDBJSONSchema } from 'mongodb-schema'; import { getCoordinatesForNewNode } from '@mongodb-js/diagramming'; import { collectionToDiagramNode } from '../utils/nodes-and-edges'; import toNS from 'mongodb-ns'; -import { traverseSchema } from '../utils/nodes-and-edges'; +import { traverseSchema } from '../utils/schema-traversal'; function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { return Array.isArray(arr) && arr.length > 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 c5ccf6023d4..0da53acf959 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -9,6 +9,7 @@ import type { FieldPath, Relationship, } from '../services/data-model-storage'; +import { traverseSchema } from './schema-traversal'; function getBsonTypeName(bsonType: string) { switch (bsonType) { @@ -72,73 +73,6 @@ export const getHighlightedFields = ( return selection; }; -// TODO: add support for update -// renaming fields, deleting fields, adding fields... -// or maybe we can have two traverses? one for parsing, one for updating? -export const traverseSchema = ({ - jsonSchema, - parentFieldPath = [], - visitor, -}: { - jsonSchema: MongoDBJSONSchema; - visitor: ({ - fieldPath, - fieldTypes, - }: { - fieldPath: FieldPath; - fieldTypes: string[]; - }) => void; - parentFieldPath?: FieldPath; -}): void => { - if (!jsonSchema || !jsonSchema.properties) { - return; - } - for (const [name, field] of Object.entries(jsonSchema.properties)) { - // field has types, properties and (optional) children - // types are either direct, or from anyof - // children are either direct (properties), from anyOf, items or items.anyOf - const types: (string | string[])[] = []; - const children: (MongoDBJSONSchema | MongoDBJSONSchema[])[] = []; - if (field.bsonType) { - types.push(field.bsonType); - } - if (field.properties) { - children.push(field); - } - if (field.items) { - children.push((field.items as MongoDBJSONSchema).anyOf || field.items); - } - if (field.anyOf) { - for (const variant of field.anyOf) { - if (variant.bsonType) { - types.push(variant.bsonType); - } - if (variant.properties) { - children.push(variant); - } - if (variant.items) { - children.push(variant.items); - } - } - } - - const newFieldPath = [...parentFieldPath, name]; - - visitor({ - fieldPath: newFieldPath, - fieldTypes: types.flat(), - }); - - children.flat().forEach((child) => - traverseSchema({ - jsonSchema: child, - visitor, - parentFieldPath: newFieldPath, - }) - ); - } -}; - export const getFieldsFromSchema = ({ jsonSchema, highlightedFields = [], From 18eb1e42828b0dbe4c14342a20093df64663cf71 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Fri, 22 Aug 2025 18:56:08 +0200 Subject: [PATCH 06/19] field type --- .../document-list/element-editors.tsx | 1 + .../drawer/field-drawer-content.tsx | 54 ++++- .../src/utils/schema-traversal.tsx | 187 ++++++++++++++++++ 3 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 packages/compass-data-modeling/src/utils/schema-traversal.tsx diff --git a/packages/compass-components/src/components/document-list/element-editors.tsx b/packages/compass-components/src/components/document-list/element-editors.tsx index 95d784feb92..49d2ca2ec87 100644 --- a/packages/compass-components/src/components/document-list/element-editors.tsx +++ b/packages/compass-components/src/components/document-list/element-editors.tsx @@ -10,6 +10,7 @@ import { mergeProps } from '../../utils/merge-props'; import { documentTypography } from './typography'; import { Icon, Tooltip } from '../leafygreen'; import { useDarkMode } from '../../hooks/use-theme'; +import { lowerFirst } from 'lodash'; const maxWidth = css({ maxWidth: '100%', diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index dbdcc45bec0..1f5631fd70b 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -2,10 +2,13 @@ import React, { useMemo } from 'react'; import { connect } from 'react-redux'; import type { FieldPath, - FieldSchema, Relationship, } from '../../services/data-model-storage'; -import { TextInput } from '@mongodb-js/compass-components'; +import { + Combobox, + ComboboxOption, + TextInput, +} from '@mongodb-js/compass-components'; import toNS from 'mongodb-ns'; import { createNewRelationship, @@ -21,12 +24,14 @@ import { } from './drawer-section-components'; import { useChangeOnBlur } from './use-change-on-blur'; import { RelationshipsSection } from './relationships-section'; +import { getFieldFromSchema } from '../../utils/schema-traversal'; +import { lowerFirst } from 'lodash'; type FieldDrawerContentProps = { namespace: string; fieldPath: FieldPath; fieldPaths: FieldPath[]; - jsonSchema: FieldSchema; + types: string[]; relationships: Relationship[]; onCreateNewRelationshipClick: ({ localNamespace, @@ -50,6 +55,17 @@ type FieldDrawerContentProps = { ) => void; }; +const TYPES = [ + 'objectId', + 'string', + 'int', + 'bool', + 'date', + 'object', + 'array', +] as const; +// TODO: get the full list from somewhere + export function getIsFieldNameValid( currentFieldPath: FieldPath, existingFields: FieldPath[], @@ -87,7 +103,7 @@ const FieldDrawerContent: React.FunctionComponent = ({ namespace, fieldPath, fieldPaths, - jsonSchema, + types, relationships, onCreateNewRelationshipClick, onEditRelationshipClick, @@ -95,6 +111,7 @@ const FieldDrawerContent: React.FunctionComponent = ({ onRenameField, onChangeFieldType, }) => { + console.log({ TYPES, types }); const { value: fieldName, ...nameInputProps } = useChangeOnBlur( fieldPath[fieldPath.length - 1], (fieldName) => { @@ -133,6 +150,24 @@ const FieldDrawerContent: React.FunctionComponent = ({ errorMessage={fieldNameEditErrorMessage} /> + + + + {TYPES.map((type) => ( + + ))} + + { const model = selectCurrentModelFromState(state); return { - jsonSchema: {}, // TODO get field schema - fieldPaths: [], // TODO get field paths + types: + getFieldFromSchema({ + jsonSchema: + model.collections.find( + (collection) => collection.ns === ownProps.namespace + )?.jsonSchema ?? {}, + fieldPath: ownProps.fieldPath, + })?.fieldTypes ?? [], + fieldPaths: [], // TODO get existing field paths relationships: model.relationships.filter((r) => { const [local, foreign] = r.relationship; return ( diff --git a/packages/compass-data-modeling/src/utils/schema-traversal.tsx b/packages/compass-data-modeling/src/utils/schema-traversal.tsx new file mode 100644 index 00000000000..cc1f915ffa1 --- /dev/null +++ b/packages/compass-data-modeling/src/utils/schema-traversal.tsx @@ -0,0 +1,187 @@ +import type { MongoDBJSONSchema } from 'mongodb-schema'; +import type { FieldPath } from '../services/data-model-storage'; + +// TODO: add support for update +// renaming fields, deleting fields, adding fields... +// or maybe we can have two traverses? one for parsing, one for updating? +/** + * Traverses a MongoDB JSON schema and calls the visitor for each field. + * + * @param {Object} params - The parameters object. + * @param {MongoDBJSONSchema} params.jsonSchema - The JSON schema to traverse. + * @param {function} params.visitor - Function called for each field. + * @returns {void} + */ +export const traverseSchema = ({ + jsonSchema, + parentFieldPath = [], + visitor, +}: { + jsonSchema: MongoDBJSONSchema; + visitor: ({ + fieldPath, + fieldTypes, + }: { + fieldPath: FieldPath; + fieldTypes: string[]; + }) => void; + parentFieldPath?: FieldPath; +}): void => { + if (!jsonSchema || !jsonSchema.properties) { + return; + } + for (const [name, field] of Object.entries(jsonSchema.properties)) { + // field has types, properties and (optional) children + // types are either direct, or from anyof + // children are either direct (properties), from anyOf, items or items.anyOf + const types: (string | string[])[] = []; + const children: (MongoDBJSONSchema | MongoDBJSONSchema[])[] = []; + if (field.bsonType) { + types.push(field.bsonType); + } + if (field.properties) { + children.push(field); + } + if (field.items) { + children.push((field.items as MongoDBJSONSchema).anyOf || field.items); + } + if (field.anyOf) { + for (const variant of field.anyOf) { + if (variant.bsonType) { + types.push(variant.bsonType); + } + if (variant.properties) { + children.push(variant); + } + if (variant.items) { + children.push(variant.items); + } + } + } + + const newFieldPath = [...parentFieldPath, name]; + + visitor({ + fieldPath: newFieldPath, + fieldTypes: types.flat(), + }); + + children.flat().forEach((child) => + traverseSchema({ + jsonSchema: child, + visitor, + parentFieldPath: newFieldPath, + }) + ); + } +}; + +function searchItemsForChild( + items: MongoDBJSONSchema['items'], + child: string +): MongoDBJSONSchema | undefined { + // When items is an array, this indicates multiple non-complex types + if (!items || Array.isArray(items)) return undefined; + // Nested array - we go deeper + if (items.items) { + const result = searchItemsForChild(items.items, child); + if (result) return result; + } + // Array of single type and that type is an object + if (items.properties && items.properties[child]) { + return items.properties[child]; + } + // Array of multiple types, possibly including objects + if (items.anyOf) { + for (const item of items.anyOf) { + if (item.properties && item.properties[child]) { + return item.properties[child]; + } + } + } + return undefined; +} + +/** + * Finds a single field in a MongoDB JSON schema. + * + * @param {Object} params - The parameters object. + * @param {MongoDBJSONSchema} params.jsonSchema - The JSON schema to traverse. + * @param {FieldPath} params.fieldPath - The field path to find. + * @returns {Object} result - The field information. + * @returns {FieldPath[]} result.fieldPath - The full path to the found field. + * @returns {string[]} result.fieldTypes - Flat list of BSON types for the found field. + * @returns {MongoDBJSONSchema} result.jsonSchema - The JSON schema node for the found field. + + */ +export const getFieldFromSchema = ({ + jsonSchema, + fieldPath, + parentFieldPath = [], +}: { + jsonSchema: MongoDBJSONSchema; + fieldPath: FieldPath; + parentFieldPath?: FieldPath; +}): + | { + fieldTypes: string[]; + jsonSchema: MongoDBJSONSchema; + } + | undefined => { + const nextInPath = fieldPath[0]; + const remainingFieldPath = fieldPath.slice(1); + let nextStep: MongoDBJSONSchema | undefined; + if (jsonSchema.properties && jsonSchema.properties[nextInPath]) { + nextStep = jsonSchema.properties[nextInPath]; + } + if (!nextStep && jsonSchema.items) { + nextStep = searchItemsForChild(jsonSchema.items, nextInPath); + } + if (!nextStep && jsonSchema.anyOf) { + for (const variant of jsonSchema.anyOf) { + if (variant.properties && variant.properties[nextInPath]) { + nextStep = variant.properties[nextInPath]; + break; + } + if (variant.items) { + nextStep = searchItemsForChild(variant.items, nextInPath); + if (nextStep) break; + } + } + } + if (!nextStep) { + throw new Error('Field not found in schema'); + } + + // Reached the end of path, return the field information + if (fieldPath.length === 1) { + const types: string[] = []; + if (nextStep.bsonType) { + if (Array.isArray(nextStep.bsonType)) { + types.push(...nextStep.bsonType); + } else { + types.push(nextStep.bsonType); + } + } + if (nextStep.anyOf) { + types.push( + ...nextStep.anyOf.flatMap((variant) => variant.bsonType || []) + ); + } + return { + fieldTypes: types, + jsonSchema: nextStep, + }; + } + console.log('Search lower', { + jsonSchema: nextStep, + fieldPath: remainingFieldPath, + parentFieldPath: [...parentFieldPath, nextInPath], + }); + // Continue searching in the next step + return getFieldFromSchema({ + jsonSchema: nextStep, + fieldPath: remainingFieldPath, + parentFieldPath: [...parentFieldPath, nextInPath], + }); +}; From e7578462d7a9ff4e188719ba71abe80c5e33d4f2 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Fri, 22 Aug 2025 18:59:07 +0200 Subject: [PATCH 07/19] cleanup --- .../src/components/document-list/element-editors.tsx | 1 - packages/compass-data-modeling/src/utils/schema-traversal.tsx | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/compass-components/src/components/document-list/element-editors.tsx b/packages/compass-components/src/components/document-list/element-editors.tsx index 49d2ca2ec87..95d784feb92 100644 --- a/packages/compass-components/src/components/document-list/element-editors.tsx +++ b/packages/compass-components/src/components/document-list/element-editors.tsx @@ -10,7 +10,6 @@ import { mergeProps } from '../../utils/merge-props'; import { documentTypography } from './typography'; import { Icon, Tooltip } from '../leafygreen'; import { useDarkMode } from '../../hooks/use-theme'; -import { lowerFirst } from 'lodash'; const maxWidth = css({ maxWidth: '100%', diff --git a/packages/compass-data-modeling/src/utils/schema-traversal.tsx b/packages/compass-data-modeling/src/utils/schema-traversal.tsx index cc1f915ffa1..37764bd9098 100644 --- a/packages/compass-data-modeling/src/utils/schema-traversal.tsx +++ b/packages/compass-data-modeling/src/utils/schema-traversal.tsx @@ -1,9 +1,6 @@ import type { MongoDBJSONSchema } from 'mongodb-schema'; import type { FieldPath } from '../services/data-model-storage'; -// TODO: add support for update -// renaming fields, deleting fields, adding fields... -// or maybe we can have two traverses? one for parsing, one for updating? /** * Traverses a MongoDB JSON schema and calls the visitor for each field. * From 28550da1c5873171ee77fac2ef7e73fce53fed43 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 10:36:26 +0200 Subject: [PATCH 08/19] bsontype --- .../components/drawer/field-drawer-content.tsx | 16 ++++------------ .../src/utils/schema-traversal.tsx | 5 ----- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 1f5631fd70b..3a8f313de76 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -10,6 +10,7 @@ import { TextInput, } from '@mongodb-js/compass-components'; import toNS from 'mongodb-ns'; +import { BSONType } from 'mongodb'; import { createNewRelationship, deleteRelationship, @@ -55,16 +56,7 @@ type FieldDrawerContentProps = { ) => void; }; -const TYPES = [ - 'objectId', - 'string', - 'int', - 'bool', - 'date', - 'object', - 'array', -] as const; -// TODO: get the full list from somewhere +const BSON_TYPES = Object.keys(BSONType); export function getIsFieldNameValid( currentFieldPath: FieldPath, @@ -111,7 +103,6 @@ const FieldDrawerContent: React.FunctionComponent = ({ onRenameField, onChangeFieldType, }) => { - console.log({ TYPES, types }); const { value: fieldName, ...nameInputProps } = useChangeOnBlur( fieldPath[fieldPath.length - 1], (fieldName) => { @@ -156,10 +147,11 @@ const FieldDrawerContent: React.FunctionComponent = ({ aria-label="Datatype" disabled={true} // TODO: enable when field type change is implemented value={types} + size="small" multiselect={true} clearable={false} > - {TYPES.map((type) => ( + {BSON_TYPES.map((type) => ( Date: Mon, 25 Aug 2025 12:09:35 +0200 Subject: [PATCH 09/19] add side panel tests --- .../drawer/diagram-editor-side-panel.spec.tsx | 294 +++++++++++++----- .../drawer/diagram-editor-side-panel.tsx | 26 +- .../drawer/field-drawer-content.tsx | 2 + .../data-model-with-relationships.json | 13 +- 4 files changed, 241 insertions(+), 94 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx index f8ab1e41cb5..afcc28682d2 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx @@ -15,6 +15,7 @@ import { selectCollection, selectCurrentModelFromState, selectRelationship, + selectField, } from '../../store/diagram'; import dataModel from '../../../test/fixtures/data-model-with-relationships.json'; import type { @@ -53,6 +54,14 @@ async function comboboxSelectItem( }); } +function getMultiComboboxValues(testId: string) { + const combobox = screen.getByTestId(testId); + expect(combobox).to.be.visible; + return within(combobox) + .getAllByRole('option') + .map((option) => option.textContent); +} + describe('DiagramEditorSidePanel', function () { before(function () { // TODO(COMPASS-9618): skip in electron runtime for now, drawer has issues rendering @@ -127,6 +136,43 @@ describe('DiagramEditorSidePanel', function () { ).to.be.visible; }); + describe('Field context drawer', function () { + it('should render a field context drawer when field is clicked', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectField('flights.airlines', ['alias'])); + + await waitForDrawerToOpen(); + expect(screen.getByTitle('airlines.alias')).to.be.visible; + + const nameInput = screen.getByLabelText('Field name'); + expect(nameInput).to.be.visible; + expect(nameInput).to.have.value('alias'); + + const selectedTypes = getMultiComboboxValues('lg-combobox-datatype'); + expect(selectedTypes).to.have.lengthOf(2); + expect(selectedTypes).to.include('string'); + expect(selectedTypes).to.include('int'); + }); + + it('should render a nested field context drawer when field is clicked', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch( + selectField('flights.routes', ['airline', 'id']) + ); + + await waitForDrawerToOpen(); + expect(screen.getByTitle('routes.airline.id')).to.be.visible; + + const nameInput = screen.getByLabelText('Field name'); + expect(nameInput).to.be.visible; + expect(nameInput).to.have.value('id'); + + const selectedTypes = getMultiComboboxValues('lg-combobox-datatype'); + expect(selectedTypes).to.have.lengthOf(1); + expect(selectedTypes).to.include('string'); + }); + }); + it('should change the content of the drawer when selecting different items', async function () { const result = renderDrawer(); @@ -165,93 +211,183 @@ describe('DiagramEditorSidePanel', function () { expect(screen.getByLabelText('Name')).to.have.value('planes'); }); - it('should open and edit relationship starting from collection', async function () { - const result = renderDrawer(); - result.plugin.store.dispatch(selectCollection('flights.countries')); + describe('Collection -> Relationships', function () { + it('should add a relationship starting from a collection', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectCollection('flights.countries')); - await waitForDrawerToOpen(); + await waitForDrawerToOpen(); + expect(screen.getByLabelText('Name')).to.have.value('countries'); - expect(screen.getByLabelText('Name')).to.have.value('countries'); - - // Open relationshipt editing form - const relationshipItem = screen - .getByText('countries.name → airports.Country') - .closest('li'); - expect(relationshipItem).to.be.visible; - userEvent.click( - within(relationshipItem!).getByRole('button', { - name: 'Edit relationship', - }) - ); - expect(screen.getByLabelText('Local field')).to.be.visible; - - // Select new values - await comboboxSelectItem('Local collection', 'planes'); - await comboboxSelectItem('Local field', 'name'); - await comboboxSelectItem('Foreign collection', 'countries'); - await comboboxSelectItem('Foreign field', 'iso_code'); - - userEvent.click(screen.getByRole('textbox', { name: 'Notes' })); - userEvent.type( - screen.getByRole('textbox', { name: 'Notes' }), - 'Note about the relationship' - ); - userEvent.tab(); - - // We should be testing through rendered UI but as it's really hard to make - // diagram rendering in tests property, we are just validating the final - // model here - const modifiedRelationship = selectCurrentModelFromState( - result.plugin.store.getState() - ).relationships.find((r: Relationship) => { - return r.id === '204b1fc0-601f-4d62-bba3-38fade71e049'; + // Click on add relationship button + userEvent.click(screen.getByRole('button', { name: 'Add relationship' })); + + // Collection is pre-selected + expect(screen.getByLabelText('Local collection')).to.be.visible; + expect(screen.getByLabelText('Local collection')).to.have.value( + 'countries' + ); }); - expect(modifiedRelationship) - .to.have.property('relationship') - .deep.eq([ - { - ns: 'flights.planes', - fields: ['name'], - cardinality: 1, - }, - { - ns: 'flights.countries', - fields: ['iso_code'], - cardinality: 100, - }, - ]); - - expect(modifiedRelationship).to.have.property( - 'note', - 'Note about the relationship' - ); + it('should open and edit relationship starting from a collection', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectCollection('flights.countries')); + + await waitFor(() => { + expect(screen.getByLabelText('Name')).to.have.value('countries'); + }); + + // Open relationshipt editing form + const relationshipItem = screen + .getByText('countries.name → airports.Country') + .closest('li'); + expect(relationshipItem).to.be.visible; + userEvent.click( + within(relationshipItem!).getByRole('button', { + name: 'Edit relationship', + }) + ); + expect(screen.getByLabelText('Local field')).to.be.visible; + + // Select new values + await comboboxSelectItem('Local collection', 'planes'); + await comboboxSelectItem('Local field', 'name'); + await comboboxSelectItem('Foreign collection', 'countries'); + await comboboxSelectItem('Foreign field', 'iso_code'); + + userEvent.click(screen.getByRole('textbox', { name: 'Notes' })); + userEvent.type( + screen.getByRole('textbox', { name: 'Notes' }), + 'Note about the relationship' + ); + userEvent.tab(); + + // We should be testing through rendered UI but as it's really hard to make + // diagram rendering in tests property, we are just validating the final + // model here + const modifiedRelationship = selectCurrentModelFromState( + result.plugin.store.getState() + ).relationships.find((r: Relationship) => { + return r.id === '204b1fc0-601f-4d62-bba3-38fade71e049'; + }); + + expect(modifiedRelationship) + .to.have.property('relationship') + .deep.eq([ + { + ns: 'flights.planes', + fields: ['name'], + cardinality: 1, + }, + { + ns: 'flights.countries', + fields: ['iso_code'], + cardinality: 100, + }, + ]); + + expect(modifiedRelationship).to.have.property( + 'note', + 'Note about the relationship' + ); + }); + + it('should delete a relationship from a collection', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectCollection('flights.countries')); + + await waitFor(() => { + expect(screen.getByLabelText('Name')).to.have.value('countries'); + }); + + // Find the relationhip item + const relationshipItem = screen + .getByText('countries.name → airports.Country') + .closest('li'); + expect(relationshipItem).to.be.visible; + + // Delete relationship + userEvent.click( + within(relationshipItem!).getByRole('button', { + name: 'Delete relationship', + }) + ); + + await waitFor(() => { + expect(screen.queryByText('countries.name → airports.Country')).not.to + .exist; + }); + }); }); - it('should delete a relationship from collection', async function () { - const result = renderDrawer(); - result.plugin.store.dispatch(selectCollection('flights.countries')); + describe('Field -> Relationships', function () { + it('should add a relationship starting from a field', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectField('flights.countries', ['name'])); - await waitForDrawerToOpen(); + await waitForDrawerToOpen(); + expect(screen.getByLabelText('Field name')).to.have.value('name'); - expect(screen.getByLabelText('Name')).to.have.value('countries'); + // Click on add relationship button + userEvent.click(screen.getByRole('button', { name: 'Add relationship' })); - // Find the relationhip item - const relationshipItem = screen - .getByText('countries.name → airports.Country') - .closest('li'); - expect(relationshipItem).to.be.visible; + // Collection and field are pre-selected + expect(screen.getByLabelText('Local collection')).to.be.visible; + expect(screen.getByLabelText('Local collection')).to.have.value( + 'countries' + ); + expect(screen.getByLabelText('Local field')).to.be.visible; + expect(screen.getByLabelText('Local field')).to.have.value('name'); + }); - // Delete relationship - userEvent.click( - within(relationshipItem!).getByRole('button', { - name: 'Delete relationship', - }) - ); + it('should open a relationship starting from a field', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectField('flights.countries', ['name'])); - await waitFor(() => { - expect(screen.queryByText('countries.name → airports.Country')).not.to - .exist; + await waitFor(() => { + expect(screen.getByLabelText('Field name')).to.have.value('name'); + }); + + // Open relationshipt editing form + const relationshipItem = screen + .getByText('airports.Country') + .closest('li'); + expect(relationshipItem).to.be.visible; + userEvent.click( + within(relationshipItem!).getByRole('button', { + name: 'Edit relationship', + }) + ); + expect(screen.getByLabelText('Local field')).to.be.visible; + expect(screen.getByLabelText('Local field')).to.have.value('name'); + expect(screen.getByLabelText('Foreign field')).to.be.visible; + expect(screen.getByLabelText('Foreign field')).to.have.value('Country'); + }); + + it('should delete a relationship from a field', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectField('flights.countries', ['name'])); + + await waitFor(() => { + expect(screen.getByLabelText('Field name')).to.have.value('name'); + }); + + // Find the relationhip item + const relationshipItem = screen + .getByText('airports.Country') + .closest('li'); + expect(relationshipItem).to.be.visible; + + // Delete relationship + userEvent.click( + within(relationshipItem!).getByRole('button', { + name: 'Delete relationship', + }) + ); + + await waitFor(() => { + expect(screen.queryByText('airports.Country')).not.to.exist; + }); }); }); @@ -262,7 +398,7 @@ describe('DiagramEditorSidePanel', function () { await waitForDrawerToOpen(); - expect(screen.getByTitle('flights.airlines')).to.be.visible; + expect(screen.getByTitle('airlines')).to.be.visible; const nameInput = screen.getByLabelText('Name'); expect(nameInput).to.be.visible; @@ -365,7 +501,7 @@ describe('DiagramEditorSidePanel', function () { expect(newCollection).to.exist; // See the name in the input - expect(screen.getByText('flights.pineapple')).to.be.visible; + expect(screen.getByText('pineapple')).to.be.visible; }); it('should prevent editing to an empty collection name', async function () { @@ -435,4 +571,4 @@ describe('DiagramEditorSidePanel', function () { expect(notModifiedCollection).to.exist; }); }); -}); +}); \ No newline at end of file diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx index 13423ef4369..57683e4402e 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx @@ -18,6 +18,7 @@ import { import { getDefaultRelationshipName } from '../../utils'; import FieldDrawerContent from './field-drawer-content'; import type { FieldPath } from '../../services/data-model-storage'; +import { getFieldFromSchema } from '../../utils/schema-traversal'; export const DATA_MODELING_DRAWER_ID = 'data-modeling-drawer'; @@ -213,16 +214,19 @@ export default connect( } if (selected.type === 'field') { - // const collection = model.collections.find((collection) => (collection.ns === selected.namespace)); - // const field = - - // if (!field) { - // // TODO(COMPASS-9680): When the selected field doesn't exist we don't - // // show any selection. - // return { - // selectedItems: null, - // }; - // } + // TODO(COMPASS-9680): Can be cleaned up after COMPASS-9680 is done (the selection updates with undo/redo) + const collection = model.collections.find( + (collection) => collection.ns === selected.namespace + ); + const field = getFieldFromSchema({ + jsonSchema: collection?.jsonSchema ?? {}, + fieldPath: selected.fieldPath, + }); + if (!field) { + return { + selectedItems: null, + }; + } return { selectedItems: { @@ -237,6 +241,6 @@ export default connect( { onDeleteCollection: deleteCollection, onDeleteRelationship: deleteRelationship, - onDeleteField: () => {}, // TODO onDeleteField, + onDeleteField: () => {}, // TODO(COMPASS-9659) part 2 - implement onDeleteField, } )(DiagramEditorSidePanel); diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 3a8f313de76..40285158aa1 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -144,6 +144,8 @@ const FieldDrawerContent: React.FunctionComponent = ({ Date: Mon, 25 Aug 2025 13:11:17 +0200 Subject: [PATCH 10/19] cleanup --- .../src/components/diagram-editor.tsx | 4 +- .../drawer/field-drawer-content.tsx | 25 ++++---- .../src/store/diagram.ts | 61 ------------------- 3 files changed, 14 insertions(+), 76 deletions(-) diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 5b50fe9f42d..eb82e1569b0 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -269,8 +269,8 @@ const DiagramContent: React.FunctionComponent<{ openDrawer(DATA_MODELING_DRAWER_ID); }} onFieldClick={(_evt, { id: fieldPath, nodeId: namespace }) => { - _evt.stopPropagation(); // TODO: should this be handled by the diagramming package? - if (!Array.isArray(fieldPath)) return; // TODO: could be avoided with generics in the diagramming package + _evt.stopPropagation(); // TODO(COMPASS-9659): should this be handled by the diagramming package? + if (!Array.isArray(fieldPath)) return; // TODO(COMPASS-9659): could be avoided with generics in the diagramming package onFieldSelect(namespace, fieldPath); openDrawer(DATA_MODELING_DRAWER_ID); }} diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 40285158aa1..9ab75289e16 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -14,7 +14,6 @@ import { BSONType } from 'mongodb'; import { createNewRelationship, deleteRelationship, - renameField, selectCurrentModelFromState, selectRelationship, } from '../../store/diagram'; @@ -26,7 +25,6 @@ import { import { useChangeOnBlur } from './use-change-on-blur'; import { RelationshipsSection } from './relationships-section'; import { getFieldFromSchema } from '../../utils/schema-traversal'; -import { lowerFirst } from 'lodash'; type FieldDrawerContentProps = { namespace: string; @@ -51,8 +49,8 @@ type FieldDrawerContentProps = { onChangeFieldType: ( namespace: string, fieldPath: FieldPath, - fromBsonType: string, - toBsonType: string + fromBsonType: string | string[], + toBsonType: string | string[] ) => void; }; @@ -126,6 +124,10 @@ const FieldDrawerContent: React.FunctionComponent = ({ [fieldPath, fieldPaths, fieldName] ); + const handleTypeChange = (newTypes: string | string[]) => { + onChangeFieldType(namespace, fieldPath, types, newTypes); + }; + return ( <> @@ -147,18 +149,15 @@ const FieldDrawerContent: React.FunctionComponent = ({ data-testid="lg-combobox-datatype" label="Datatype" aria-label="Datatype" - disabled={true} // TODO: enable when field type change is implemented + disabled={true} // TODO(COMPASS-9659): enable when field type change is implemented value={types} size="small" multiselect={true} clearable={false} + onChange={handleTypeChange} > {BSON_TYPES.map((type) => ( - + ))} @@ -206,7 +205,7 @@ export default connect( )?.jsonSchema ?? {}, fieldPath: ownProps.fieldPath, })?.fieldTypes ?? [], - fieldPaths: [], // TODO get existing field paths + fieldPaths: [], // TODO(COMPASS-9659): get existing field paths relationships: model.relationships.filter((r) => { const [local, foreign] = r.relationship; return ( @@ -224,7 +223,7 @@ export default connect( onCreateNewRelationshipClick: createNewRelationship, onEditRelationshipClick: selectRelationship, onDeleteRelationshipClick: deleteRelationship, - onRenameField: renameField, - onChangeFieldType: () => {}, // TODO: updateFieldSchema, + onRenameField: () => {}, // TODO(COMPASS-9659): renameField, + onChangeFieldType: () => {}, // TODO(COMPASS-9659): updateFieldSchema, } )(FieldDrawerContent); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 9afd8d5b9e7..1765f514feb 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -510,29 +510,6 @@ export function renameCollection( }; } -export function renameField( - ns: string, - from: FieldPath, - to: FieldPath -): DataModelingThunkAction< - void, - ApplyEditAction | ApplyEditFailedAction | CollectionSelectedAction -> { - return (dispatch) => { - const edit: Omit< - Extract, - 'id' | 'timestamp' - > = { - type: 'RenameField', - ns, - from, - to, - }; - - dispatch(applyEdit(edit)); - }; -} - export function applyEdit( rawEdit: EditAction ): DataModelingThunkAction { @@ -859,44 +836,6 @@ function _applyEdit(edit: Edit, model?: StaticModel): StaticModel { })), }; } - case 'RenameField': { - return { - ...model, - // Update relationships to point to the renamed field. - relationships: model.relationships.map((relationship) => { - const [local, foreign] = relationship.relationship; - - return { - ...relationship, - relationship: [ - { - ...local, - fields: - local.ns === edit.ns && - JSON.stringify(local.fields) === JSON.stringify(edit.from) - ? edit.to - : local.fields, - }, - { - ...foreign, - fields: - local.ns === edit.ns && - JSON.stringify(local.fields) === JSON.stringify(edit.from) - ? edit.to - : local.fields, - }, - ], - }; - }), - collections: model.collections.map((collection) => ({ - ...collection, - // TODO: Rename the field. - // jsonSchema: collection.ns !== edit.ns - // ? collection.jsonSchema - // : renameFieldInSchema(collection.jsonSchema, edit.from, edit.to) - })), - }; - } case 'UpdateCollectionNote': { return { ...model, From 6972bd28189ba50fe61a064bbc2c251546b89fae Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 14:20:27 +0200 Subject: [PATCH 11/19] add tests --- .../compass-data-modeling/src/utils/nodes-and-edges.spec.tsx | 2 +- packages/compass-data-modeling/src/utils/schema-traversal.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx b/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx index 63559d867e3..17803c39acb 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.spec.tsx @@ -6,7 +6,7 @@ import { render, userEvent, } from '@mongodb-js/testing-library-compass'; -import { getFieldsFromSchema } from '../utils/nodes-and-edges'; +import { getFieldsFromSchema } from './nodes-and-edges'; describe('getFieldsFromSchema', function () { const validateMixedType = async ( diff --git a/packages/compass-data-modeling/src/utils/schema-traversal.tsx b/packages/compass-data-modeling/src/utils/schema-traversal.tsx index 731a49c7906..3f569aaf94b 100644 --- a/packages/compass-data-modeling/src/utils/schema-traversal.tsx +++ b/packages/compass-data-modeling/src/utils/schema-traversal.tsx @@ -21,6 +21,7 @@ export const traverseSchema = ({ }: { fieldPath: FieldPath; fieldTypes: string[]; + fieldSchema: MongoDBJSONSchema; }) => void; parentFieldPath?: FieldPath; }): void => { @@ -61,6 +62,7 @@ export const traverseSchema = ({ visitor({ fieldPath: newFieldPath, fieldTypes: types.flat(), + fieldSchema: field, }); children.flat().forEach((child) => From 997197598c797be6b5f36e1303737f2cc5e2334b Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 14:21:20 +0200 Subject: [PATCH 12/19] add file --- .../src/utils/schema-traversal.spec.tsx | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx diff --git a/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx b/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx new file mode 100644 index 00000000000..df0b2306a61 --- /dev/null +++ b/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx @@ -0,0 +1,456 @@ +import { expect } from 'chai'; +import { traverseSchema, getFieldFromSchema } from './schema-traversal'; +import Sinon from 'sinon'; + +describe('traverseSchema', function () { + let sandbox: Sinon.SinonSandbox; + let visitor: Sinon.SinonSpy; + + beforeEach(function () { + sandbox = Sinon.createSandbox(); + visitor = sandbox.spy(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox.resetHistory(); + }); + + describe('flat schema', function () { + it('empty schema', function () { + traverseSchema({ + visitor, + jsonSchema: {}, + }); + expect(visitor).not.to.have.been.called; + }); + + it('simple schema', function () { + traverseSchema({ + visitor, + jsonSchema: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + age: { bsonType: ['string', 'int'] }, + }, + }, + }); + expect(visitor.callCount).to.equal(2); + expect(visitor.getCall(0).args[0]).to.deep.equal({ + fieldPath: ['name'], + fieldTypes: ['string'], + fieldSchema: { bsonType: 'string' }, + }); + expect(visitor.getCall(1).args[0]).to.deep.equal({ + fieldPath: ['age'], + fieldTypes: ['string', 'int'], + fieldSchema: { bsonType: ['string', 'int'] }, + }); + }); + }); + + describe('nested schema', function () { + it('nested objects', function () { + traverseSchema({ + visitor, + jsonSchema: { + bsonType: 'object', + properties: { + person: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + address: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, + }, + }, + }, + }, + }, + }); + expect(visitor.callCount).to.equal(5); + expect(visitor.getCall(0).args[0]).to.deep.include({ + fieldPath: ['person'], + fieldTypes: ['object'], + }); + expect(visitor.getCall(1).args[0]).to.deep.include({ + fieldPath: ['person', 'name'], + fieldTypes: ['string'], + }); + expect(visitor.getCall(2).args[0]).to.deep.include({ + fieldPath: ['person', 'address'], + fieldTypes: ['object'], + }); + expect(visitor.getCall(3).args[0]).to.deep.include({ + fieldPath: ['person', 'address', 'street'], + fieldTypes: ['string'], + }); + expect(visitor.getCall(4).args[0]).to.deep.include({ + fieldPath: ['person', 'address', 'city'], + fieldTypes: ['string'], + }); + }); + + it('a mixed type with objects', function () { + traverseSchema({ + visitor, + jsonSchema: { + bsonType: 'object', + properties: { + names: { + anyOf: [ + { bsonType: 'string' }, + { + bsonType: 'object', + properties: { + first: { bsonType: 'string' }, + last: { bsonType: 'string' }, + }, + }, + ], + }, + }, + }, + }); + expect(visitor.callCount).to.equal(3); + expect(visitor.getCall(0).args[0]).to.deep.include({ + fieldPath: ['names'], + fieldTypes: ['string', 'object'], + }); + expect(visitor.getCall(1).args[0]).to.deep.include({ + fieldPath: ['names', 'first'], + fieldTypes: ['string'], + }); + expect(visitor.getCall(2).args[0]).to.deep.include({ + fieldPath: ['names', 'last'], + fieldTypes: ['string'], + }); + }); + + it('array of objects', function () { + traverseSchema({ + visitor, + jsonSchema: { + bsonType: 'object', + properties: { + addresses: { + bsonType: 'array', + items: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, + }, + }, + }, + }, + }); + expect(visitor.callCount).to.equal(3); + expect(visitor.getCall(0).args[0]).to.deep.include({ + fieldPath: ['addresses'], + fieldTypes: ['array'], + }); + expect(visitor.getCall(1).args[0]).to.deep.include({ + fieldPath: ['addresses', 'street'], + fieldTypes: ['string'], + }); + expect(visitor.getCall(2).args[0]).to.deep.include({ + fieldPath: ['addresses', 'city'], + fieldTypes: ['string'], + }); + }); + + it('an array of mixed items (including objects)', function () { + traverseSchema({ + visitor, + jsonSchema: { + bsonType: 'object', + properties: { + todos: { + bsonType: 'array', + items: { + anyOf: [ + { + bsonType: 'object', + properties: { + title: { bsonType: 'string' }, + completed: { bsonType: 'bool' }, + }, + }, + { bsonType: 'string' }, + ], + }, + }, + }, + }, + }); + expect(visitor.callCount).to.equal(3); + expect(visitor.getCall(0).args[0]).to.deep.include({ + fieldPath: ['todos'], + fieldTypes: ['array'], + }); + expect(visitor.getCall(1).args[0]).to.deep.include({ + fieldPath: ['todos', 'title'], + fieldTypes: ['string'], + }); + expect(visitor.getCall(2).args[0]).to.deep.include({ + fieldPath: ['todos', 'completed'], + fieldTypes: ['bool'], + }); + }); + }); +}); + +describe('getFieldFromSchema', function () { + describe('field not found', function () { + it('empty schema', function () { + expect(() => + getFieldFromSchema({ + fieldPath: ['name'], + jsonSchema: {}, + }) + ).to.throw('Field not found in schema'); + }); + + it('wrong path', function () { + expect(() => + getFieldFromSchema({ + fieldPath: ['address', 'age'], + jsonSchema: { + bsonType: 'object', + properties: { + person: { + bsonType: 'object', + properties: { + age: { bsonType: 'int' }, + name: { bsonType: 'string' }, + }, + }, + address: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, + }, + }, + }, + }) + ).to.throw('Field not found in schema'); + }); + }); + + describe('flat schema', function () { + it('single type', function () { + const result = getFieldFromSchema({ + fieldPath: ['name'], + jsonSchema: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + age: { bsonType: ['string', 'int'] }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['string'], + jsonSchema: { bsonType: 'string' }, + }); + }); + it('simple mixed type', function () { + const result = getFieldFromSchema({ + fieldPath: ['age'], + jsonSchema: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + age: { bsonType: ['string', 'int'] }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['string', 'int'], + jsonSchema: { bsonType: ['string', 'int'] }, + }); + }); + }); + + describe('nested schema', function () { + it('nested objects - parent', function () { + const result = getFieldFromSchema({ + fieldPath: ['person', 'address'], + jsonSchema: { + bsonType: 'object', + properties: { + person: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + address: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, + }, + }, + }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['object'], + jsonSchema: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, + }, + }); + }); + + it('nested objects - leaf', function () { + const result = getFieldFromSchema({ + fieldPath: ['person', 'address', 'city'], + jsonSchema: { + bsonType: 'object', + properties: { + person: { + bsonType: 'object', + properties: { + name: { bsonType: 'string' }, + address: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, + }, + }, + }, + }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['string'], + jsonSchema: { bsonType: 'string' }, + }); + }); + + it('nested in a mixed type', function () { + const result = getFieldFromSchema({ + fieldPath: ['names', 'first'], + jsonSchema: { + bsonType: 'object', + properties: { + names: { + anyOf: [ + { bsonType: 'string' }, + { + bsonType: 'object', + properties: { + first: { bsonType: 'string' }, + last: { bsonType: 'string' }, + }, + }, + ], + }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['string'], + jsonSchema: { bsonType: 'string' }, + }); + }); + + it('has a mixed type', function () { + const result = getFieldFromSchema({ + fieldPath: ['names'], + jsonSchema: { + bsonType: 'object', + properties: { + names: { + anyOf: [ + { bsonType: 'string' }, + { + bsonType: 'object', + properties: { + first: { bsonType: 'string' }, + last: { bsonType: 'string' }, + }, + }, + ], + }, + }, + }, + }); + expect(result).to.deep.include({ + fieldTypes: ['string', 'object'], + }); + }); + + it('nested in an array of objects', function () { + const result = getFieldFromSchema({ + fieldPath: ['addresses', 'streetNumber'], + jsonSchema: { + bsonType: 'object', + properties: { + addresses: { + bsonType: 'array', + items: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + streetNumber: { bsonType: ['int', 'string'] }, + city: { bsonType: 'string' }, + }, + }, + }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['int', 'string'], + jsonSchema: { bsonType: ['int', 'string'] }, + }); + }); + + it('nested in an array of mixed items (including objects)', function () { + const result = getFieldFromSchema({ + fieldPath: ['todos', 'completed'], + jsonSchema: { + bsonType: 'object', + properties: { + todos: { + bsonType: 'array', + items: { + anyOf: [ + { + bsonType: 'object', + properties: { + title: { bsonType: 'string' }, + completed: { bsonType: 'bool' }, + }, + }, + { bsonType: 'string' }, + ], + }, + }, + }, + }, + }); + expect(result).to.deep.equal({ + fieldTypes: ['bool'], + jsonSchema: { bsonType: 'bool' }, + }); + }); + }); +}); From 7078026829367fc7791f39156203fbfe3234f6db Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 15:38:06 +0200 Subject: [PATCH 13/19] fix formatting after rebase --- .../src/components/drawer/collection-drawer-content.tsx | 5 ++++- .../src/components/drawer/diagram-editor-side-panel.spec.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx index c2e567ce6a2..f66aaf0c81a 100644 --- a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { connect } from 'react-redux'; import toNS from 'mongodb-ns'; -import type { DataModelCollection, Relationship } from '../../services/data-model-storage'; +import type { + DataModelCollection, + Relationship, +} from '../../services/data-model-storage'; import { TextInput, TextArea } from '@mongodb-js/compass-components'; import { createNewRelationship, diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx index afcc28682d2..2728b0675b4 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx @@ -571,4 +571,4 @@ describe('DiagramEditorSidePanel', function () { expect(notModifiedCollection).to.exist; }); }); -}); \ No newline at end of file +}); From 96757c65784e3b47d22c4d82124cb85a7cbddf8e Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 16:24:14 +0200 Subject: [PATCH 14/19] areFieldPathsEqual --- .../components/drawer/field-drawer-content.tsx | 17 ++++++++--------- .../src/utils/nodes-and-edges.tsx | 8 ++++---- .../compass-data-modeling/src/utils/utils.ts | 8 ++++++++ 3 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 packages/compass-data-modeling/src/utils/utils.ts diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 9ab75289e16..44ee6bf5415 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -25,6 +25,7 @@ import { import { useChangeOnBlur } from './use-change-on-blur'; import { RelationshipsSection } from './relationships-section'; import { getFieldFromSchema } from '../../utils/schema-traversal'; +import { areFieldPathsEqual } from '../../utils/utils'; type FieldDrawerContentProps = { namespace: string; @@ -73,10 +74,7 @@ export function getIsFieldNameValid( } const fieldsNamesWithoutCurrent = existingFields - .filter( - (fieldPath) => - JSON.stringify(fieldPath) !== JSON.stringify(currentFieldPath) - ) + .filter((fieldPath) => !areFieldPathsEqual(fieldPath, currentFieldPath)) .map((fieldPath) => fieldPath[fieldPath.length - 1]); const isDuplicate = fieldsNamesWithoutCurrent.some( @@ -169,7 +167,8 @@ const FieldDrawerContent: React.FunctionComponent = ({ getRelationshipLabel={([local, foreign]) => { const labelField = local.ns === namespace && - JSON.stringify(local.fields) === JSON.stringify(fieldPath) + local.fields && + areFieldPathsEqual(local.fields, fieldPath) ? foreign : local; return [ @@ -210,11 +209,11 @@ export default connect( const [local, foreign] = r.relationship; return ( (local.ns === ownProps.namespace && - JSON.stringify(local.fields) === - JSON.stringify(ownProps.fieldPath)) || + local.fields && + areFieldPathsEqual(local.fields, ownProps.fieldPath)) || (foreign.ns === ownProps.namespace && - JSON.stringify(foreign.fields) === - JSON.stringify(ownProps.fieldPath)) + foreign.fields && + areFieldPathsEqual(foreign.fields, ownProps.fieldPath)) ); }), }; 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 0da53acf959..d8f800b0a52 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -10,6 +10,7 @@ import type { Relationship, } from '../services/data-model-storage'; import { traverseSchema } from './schema-traversal'; +import { areFieldPathsEqual } from './utils'; function getBsonTypeName(bsonType: string) { switch (bsonType) { @@ -100,12 +101,11 @@ export const getFieldsFromSchema = ({ ? ['key'] : [], selectable: true, - selected: JSON.stringify(fieldPath) === JSON.stringify(selectedField), + selected: areFieldPathsEqual(fieldPath, selectedField ?? []), variant: highlightedFields.length && - highlightedFields.some( - (highlightedField) => - JSON.stringify(fieldPath) === JSON.stringify(highlightedField) + highlightedFields.some((highlightedField) => + areFieldPathsEqual(fieldPath, highlightedField) ) ? 'preview' : undefined, diff --git a/packages/compass-data-modeling/src/utils/utils.ts b/packages/compass-data-modeling/src/utils/utils.ts new file mode 100644 index 00000000000..fc868008992 --- /dev/null +++ b/packages/compass-data-modeling/src/utils/utils.ts @@ -0,0 +1,8 @@ +import type { FieldPath } from '../services/data-model-storage'; + +export function areFieldPathsEqual( + fieldA: FieldPath, + fieldB: FieldPath +): boolean { + return JSON.stringify(fieldA) === JSON.stringify(fieldB); +} From 1f3eaaf2a77612cf63ddc8cbbcc29fe134109718 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 17:44:42 +0200 Subject: [PATCH 15/19] use default name for all relationships --- .../components/drawer/collection-drawer-content.tsx | 2 -- .../drawer/diagram-editor-side-panel.spec.tsx | 4 ++-- .../src/components/drawer/field-drawer-content.tsx | 12 ------------ .../src/components/drawer/relationships-section.tsx | 7 ++++--- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx index f66aaf0c81a..d892ced6930 100644 --- a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx @@ -15,7 +15,6 @@ import { updateCollectionNote, } from '../../store/diagram'; import type { DataModelingState } from '../../store/reducer'; -import { getDefaultRelationshipName } from '../../utils'; import { DMDrawerSection, DMFormFieldContainer, @@ -141,7 +140,6 @@ const CollectionDrawerContent: React.FunctionComponent< { onCreateNewRelationshipClick({ localNamespace: namespace }); }} diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx index 2728b0675b4..8b3a1b8dce1 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx @@ -350,7 +350,7 @@ describe('DiagramEditorSidePanel', function () { // Open relationshipt editing form const relationshipItem = screen - .getByText('airports.Country') + .getByText('countries.name → airports.Country') .closest('li'); expect(relationshipItem).to.be.visible; userEvent.click( @@ -374,7 +374,7 @@ describe('DiagramEditorSidePanel', function () { // Find the relationhip item const relationshipItem = screen - .getByText('airports.Country') + .getByText('countries.name → airports.Country') .closest('li'); expect(relationshipItem).to.be.visible; diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 44ee6bf5415..7ff84e19380 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -164,18 +164,6 @@ const FieldDrawerContent: React.FunctionComponent = ({ { - const labelField = - local.ns === namespace && - local.fields && - areFieldPathsEqual(local.fields, fieldPath) - ? foreign - : local; - return [ - labelField.ns ? toNS(labelField.ns).collection : '', - labelField.fields?.join('.'), - ].join('.'); - }} onCreateNewRelationshipClick={() => { onCreateNewRelationshipClick({ localNamespace: namespace, diff --git a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx index 3d578aeefd3..ab2bbbbc867 100644 --- a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx +++ b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx @@ -10,6 +10,7 @@ import { spacing, } from '@mongodb-js/compass-components'; import type { Relationship } from '../../services/data-model-storage'; +import { getDefaultRelationshipName } from '../../utils'; const titleBtnStyles = css({ marginLeft: 'auto', @@ -47,7 +48,6 @@ const relationshipContentStyles = css({ type RelationshipsSectionProps = { relationships: Relationship[]; emptyMessage: string; - getRelationshipLabel: (relationship: Relationship['relationship']) => string; onCreateNewRelationshipClick: () => void; onEditRelationshipClick: (rId: string) => void; onDeleteRelationshipClick: (rId: string) => void; @@ -58,7 +58,6 @@ export const RelationshipsSection: React.FunctionComponent< > = ({ relationships, emptyMessage, - getRelationshipLabel, onCreateNewRelationshipClick, onEditRelationshipClick, onDeleteRelationshipClick, @@ -85,7 +84,9 @@ export const RelationshipsSection: React.FunctionComponent< ) : (
    {relationships.map((r) => { - const relationshipLabel = getRelationshipLabel(r.relationship); + const relationshipLabel = getDefaultRelationshipName( + r.relationship + ); return (
  • Date: Mon, 25 Aug 2025 18:58:37 +0200 Subject: [PATCH 16/19] update e2e --- .../drawer/diagram-editor-side-panel.spec.tsx | 4 +- .../drawer/relationships-section.tsx | 2 +- .../tests/data-modeling-tab.test.ts | 38 ++++++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx index 8b3a1b8dce1..18542d476df 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx @@ -220,7 +220,7 @@ describe('DiagramEditorSidePanel', function () { expect(screen.getByLabelText('Name')).to.have.value('countries'); // Click on add relationship button - userEvent.click(screen.getByRole('button', { name: 'Add relationship' })); + userEvent.click(screen.getByRole('button', { name: 'Add Relationship' })); // Collection is pre-selected expect(screen.getByLabelText('Local collection')).to.be.visible; @@ -329,7 +329,7 @@ describe('DiagramEditorSidePanel', function () { expect(screen.getByLabelText('Field name')).to.have.value('name'); // Click on add relationship button - userEvent.click(screen.getByRole('button', { name: 'Add relationship' })); + userEvent.click(screen.getByRole('button', { name: 'Add Relationship' })); // Collection and field are pre-selected expect(screen.getByLabelText('Local collection')).to.be.visible; diff --git a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx index ab2bbbbc867..43fd6cbdb3d 100644 --- a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx +++ b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx @@ -73,7 +73,7 @@ export const RelationshipsSection: React.FunctionComponent< size="xsmall" onClick={onCreateNewRelationshipClick} > - Add relationship + Add Relationship } 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 33d2eee50a2..2c9a11e0a99 100644 --- a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts +++ b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts @@ -22,6 +22,7 @@ import toNS from 'mongodb-ns'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; +import type { ChainablePromiseElement } from 'webdriverio'; interface Node { id: string; @@ -42,6 +43,34 @@ type DiagramInstance = { getEdges: () => Array; }; +/** + * Clicks on a specific element at the given coordinates. + * element.click({ x: number, y: number }) doesn't work as expected, + * so we do this manually using the actions API. + * @param browser The Compass browser instance. + * @param element The WebdriverIO element to click on. + * @param coordinates The coordinates to click at. + */ +async function clickElementAtCoordinates( + browser: CompassBrowser, + element: ChainablePromiseElement, + coordinates: { + x: number; + y: number; + } +) { + await element.waitForClickable(); + const location = await element.getLocation(); + await browser + .action('pointer') + .move({ + x: location.x + coordinates.x, + y: location.y + coordinates.y, + }) + .down({ button: 0 }) // Left mouse button + .perform(); +} + async function setupDiagram( browser: CompassBrowser, options: { @@ -107,7 +136,12 @@ async function selectCollectionOnTheDiagram( const collectionNode = browser.$(Selectors.DataModelPreviewCollection(ns)); await collectionNode.waitForClickable(); - await collectionNode.click(); + await clickElementAtCoordinates(browser, collectionNode, { + // we're aiming for the header (top of the node) + // the default click is in the middle, most likely on a field + x: 100, + y: 15, + }); await drawer.waitForDisplayed(); @@ -178,7 +212,7 @@ async function dragNode( .action('pointer') .move({ x: Math.round(startPosition.x + nodeSize.width / 2), - y: Math.round(startPosition.y + nodeSize.height / 2), + y: 15, // we're aiming for the header area (top of the node) }) .down({ button: 0 }) // Left mouse button .move({ duration: 1000, origin: 'pointer', ...pointerActionMoveParams }) From 61c82bd83f3fa3c480d7cabadd98e5d8950d0811 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 25 Aug 2025 19:05:04 +0200 Subject: [PATCH 17/19] cleanup --- .../src/utils/schema-traversal.spec.tsx | 50 +++++++++---------- .../src/utils/schema-traversal.tsx | 18 +------ 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx b/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx index df0b2306a61..ff3dbf4fe8a 100644 --- a/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx +++ b/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx @@ -210,39 +210,37 @@ describe('traverseSchema', function () { describe('getFieldFromSchema', function () { describe('field not found', function () { it('empty schema', function () { - expect(() => - getFieldFromSchema({ - fieldPath: ['name'], - jsonSchema: {}, - }) - ).to.throw('Field not found in schema'); + const result = getFieldFromSchema({ + fieldPath: ['name'], + jsonSchema: {}, + }); + expect(result).to.be.undefined; }); it('wrong path', function () { - expect(() => - getFieldFromSchema({ - fieldPath: ['address', 'age'], - jsonSchema: { - bsonType: 'object', - properties: { - person: { - bsonType: 'object', - properties: { - age: { bsonType: 'int' }, - name: { bsonType: 'string' }, - }, + const result = getFieldFromSchema({ + fieldPath: ['address', 'age'], + jsonSchema: { + bsonType: 'object', + properties: { + person: { + bsonType: 'object', + properties: { + age: { bsonType: 'int' }, + name: { bsonType: 'string' }, }, - address: { - bsonType: 'object', - properties: { - street: { bsonType: 'string' }, - city: { bsonType: 'string' }, - }, + }, + address: { + bsonType: 'object', + properties: { + street: { bsonType: 'string' }, + city: { bsonType: 'string' }, }, }, }, - }) - ).to.throw('Field not found in schema'); + }, + }); + expect(result).to.be.undefined; }); }); diff --git a/packages/compass-data-modeling/src/utils/schema-traversal.tsx b/packages/compass-data-modeling/src/utils/schema-traversal.tsx index 3f569aaf94b..ae6db3abd10 100644 --- a/packages/compass-data-modeling/src/utils/schema-traversal.tsx +++ b/packages/compass-data-modeling/src/utils/schema-traversal.tsx @@ -3,16 +3,11 @@ import type { FieldPath } from '../services/data-model-storage'; /** * Traverses a MongoDB JSON schema and calls the visitor for each field. - * - * @param {Object} params - The parameters object. - * @param {MongoDBJSONSchema} params.jsonSchema - The JSON schema to traverse. - * @param {function} params.visitor - Function called for each field. - * @returns {void} */ export const traverseSchema = ({ jsonSchema, - parentFieldPath = [], visitor, + parentFieldPath = [], }: { jsonSchema: MongoDBJSONSchema; visitor: ({ @@ -103,15 +98,6 @@ function searchItemsForChild( /** * Finds a single field in a MongoDB JSON schema. - * - * @param {Object} params - The parameters object. - * @param {MongoDBJSONSchema} params.jsonSchema - The JSON schema to traverse. - * @param {FieldPath} params.fieldPath - The field path to find. - * @returns {Object} result - The field information. - * @returns {FieldPath[]} result.fieldPath - The full path to the found field. - * @returns {string[]} result.fieldTypes - Flat list of BSON types for the found field. - * @returns {MongoDBJSONSchema} result.jsonSchema - The JSON schema node for the found field. - */ export const getFieldFromSchema = ({ jsonSchema, @@ -149,7 +135,7 @@ export const getFieldFromSchema = ({ } } if (!nextStep) { - throw new Error('Field not found in schema'); + return; } // Reached the end of path, return the field information From 6c232847182756ceba61e4ff076ab4eca67d31f3 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Tue, 26 Aug 2025 10:56:04 +0200 Subject: [PATCH 18/19] cleanup --- .../src/components/drawer/field-drawer-content.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx index 7ff84e19380..a80ead34ee1 100644 --- a/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -9,7 +9,6 @@ import { ComboboxOption, TextInput, } from '@mongodb-js/compass-components'; -import toNS from 'mongodb-ns'; import { BSONType } from 'mongodb'; import { createNewRelationship, From 951a9b4c75778e2103b563c9953b28a03dc1c78e Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Tue, 26 Aug 2025 14:57:08 +0200 Subject: [PATCH 19/19] fix e2e --- packages/compass-e2e-tests/tests/data-modeling-tab.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2c9a11e0a99..0d7bc9d8338 100644 --- a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts +++ b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts @@ -212,7 +212,7 @@ async function dragNode( .action('pointer') .move({ x: Math.round(startPosition.x + nodeSize.width / 2), - y: 15, // we're aiming for the header area (top of the node) + y: Math.round(startPosition.y + 15), // we're aiming for the header area (top of the node) }) .down({ button: 0 }) // Left mouse button .move({ duration: 1000, origin: 'pointer', ...pointerActionMoveParams })