diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 73b51358fc0..eb82e1569b0 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,13 +37,13 @@ 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'; import { collectionToDiagramNode, - getSelectedFields, + getHighlightedFields, relationshipToDiagramEdge, } from '../utils/nodes-and-edges'; @@ -113,9 +114,16 @@ 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; + onCreateNewRelationship: ({ + localNamespace, + foreignNamespace, + }: { + localNamespace: string; + foreignNamespace: string; + }) => void; onRelationshipDrawn: () => void; }> = ({ diagramLabel, @@ -125,6 +133,7 @@ const DiagramContent: React.FunctionComponent<{ onMoveCollection, onCollectionSelect, onRelationshipSelect, + onFieldSelect, onDiagramBackgroundClicked, onCreateNewRelationship, onRelationshipDrawn, @@ -153,7 +162,7 @@ const DiagramContent: React.FunctionComponent<{ }, [model?.relationships, selectedItems]); const nodes = useMemo(() => { - const selectedFields = getSelectedFields( + const highlightedFields = getHighlightedFields( selectedItems, model?.relationships ); @@ -163,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, }); @@ -219,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] @@ -252,6 +268,12 @@ const DiagramContent: React.FunctionComponent<{ onRelationshipSelect(edge.id); openDrawer(DATA_MODELING_DRAWER_ID); }} + onFieldClick={(_evt, { id: fieldPath, nodeId: namespace }) => { + _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); + }} fitViewOptions={{ maxZoom: 1, minZoom: 0.25, @@ -282,6 +304,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..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 @@ -2,20 +2,10 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { connect } from 'react-redux'; import toNS from 'mongodb-ns'; import type { - Relationship, DataModelCollection, + Relationship, } from '../../services/data-model-storage'; -import { - Badge, - Button, - IconButton, - css, - palette, - spacing, - TextInput, - Icon, - TextArea, -} from '@mongodb-js/compass-components'; +import { TextInput, TextArea } from '@mongodb-js/compass-components'; import { createNewRelationship, deleteRelationship, @@ -25,12 +15,12 @@ import { updateCollectionNote, } from '../../store/diagram'; import type { DataModelingState } from '../../store/reducer'; -import { getDefaultRelationshipName } from '../../utils'; import { DMDrawerSection, DMFormFieldContainer, } from './drawer-section-components'; import { useChangeOnBlur } from './use-change-on-blur'; +import { RelationshipsSection } from './relationships-section'; type CollectionDrawerContentProps = { namespace: string; @@ -38,46 +28,17 @@ 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; 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 +137,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({ localNamespace: namespace }); + }} + onEditRelationshipClick={onEditRelationshipClick} + onDeleteRelationshipClick={onDeleteRelationshipClick} + /> 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..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 @@ -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('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; + 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('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('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 () { 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..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 @@ -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, @@ -12,8 +13,12 @@ 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'; +import { getFieldFromSchema } from '../../utils/schema-traversal'; export const DATA_MODELING_DRAWER_ID = 'data-modeling-drawer'; @@ -32,19 +37,19 @@ 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; }; +const getCollection = (namespace: string) => toNS(namespace).collection; + function DiagramEditorSidePanel({ selectedItems, onDeleteCollection, onDeleteRelationship, + onDeleteField, }: DiagramEditorSidePanelProps) { const { content, label, actions, handleAction } = useMemo(() => { if (selectedItems?.type === 'collection') { @@ -91,8 +96,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; @@ -159,7 +187,7 @@ export default connect( return { selectedItems: { ...selected, - label: selected.id, + label: getCollection(selected.id), }, }; } @@ -184,9 +212,35 @@ export default connect( }, }; } + + if (selected.type === 'field') { + // 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: { + ...selected, + label: `${getCollection( + selected.namespace + )}.${selected.fieldPath.join('.')}`, + }, + }; + } }, { onDeleteCollection: deleteCollection, onDeleteRelationship: deleteRelationship, + 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 new file mode 100644 index 00000000000..a80ead34ee1 --- /dev/null +++ b/packages/compass-data-modeling/src/components/drawer/field-drawer-content.tsx @@ -0,0 +1,215 @@ +import React, { useMemo } from 'react'; +import { connect } from 'react-redux'; +import type { + FieldPath, + Relationship, +} from '../../services/data-model-storage'; +import { + Combobox, + ComboboxOption, + TextInput, +} from '@mongodb-js/compass-components'; +import { BSONType } from 'mongodb'; +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'; +import { getFieldFromSchema } from '../../utils/schema-traversal'; +import { areFieldPathsEqual } from '../../utils/utils'; + +type FieldDrawerContentProps = { + namespace: string; + fieldPath: FieldPath; + fieldPaths: FieldPath[]; + types: string[]; + 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 | string[], + toBsonType: string | string[] + ) => void; +}; + +const BSON_TYPES = Object.keys(BSONType); + +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) => !areFieldPathsEqual(fieldPath, 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, + types, + relationships, + onCreateNewRelationshipClick, + onEditRelationshipClick, + onDeleteRelationshipClick, + onRenameField, + onChangeFieldType, +}) => { + const { value: fieldName, ...nameInputProps } = useChangeOnBlur( + fieldPath[fieldPath.length - 1], + (fieldName) => { + const trimmedName = fieldName.trim(); + if (trimmedName === fieldPath[fieldPath.length - 1]) { + 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] + ); + + const handleTypeChange = (newTypes: string | string[]) => { + onChangeFieldType(namespace, fieldPath, types, newTypes); + }; + + return ( + <> + + + + + + + + {BSON_TYPES.map((type) => ( + + ))} + + + + + { + onCreateNewRelationshipClick({ + localNamespace: namespace, + localFields: fieldPath, + }); + }} + onEditRelationshipClick={onEditRelationshipClick} + onDeleteRelationshipClick={onDeleteRelationshipClick} + /> + + ); +}; + +export default connect( + ( + state: DataModelingState, + ownProps: { namespace: string; fieldPath: FieldPath } + ) => { + const model = selectCurrentModelFromState(state); + return { + types: + getFieldFromSchema({ + jsonSchema: + model.collections.find( + (collection) => collection.ns === ownProps.namespace + )?.jsonSchema ?? {}, + fieldPath: ownProps.fieldPath, + })?.fieldTypes ?? [], + fieldPaths: [], // TODO(COMPASS-9659): get existing field paths + relationships: model.relationships.filter((r) => { + const [local, foreign] = r.relationship; + return ( + (local.ns === ownProps.namespace && + local.fields && + areFieldPathsEqual(local.fields, ownProps.fieldPath)) || + (foreign.ns === ownProps.namespace && + foreign.fields && + areFieldPathsEqual(foreign.fields, ownProps.fieldPath)) + ); + }), + }; + }, + { + onCreateNewRelationshipClick: createNewRelationship, + onEditRelationshipClick: selectRelationship, + onDeleteRelationshipClick: deleteRelationship, + onRenameField: () => {}, // TODO(COMPASS-9659): renameField, + onChangeFieldType: () => {}, // TODO(COMPASS-9659): 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..43fd6cbdb3d --- /dev/null +++ b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx @@ -0,0 +1,129 @@ +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'; +import { getDefaultRelationshipName } from '../../utils'; + +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; + onCreateNewRelationshipClick: () => void; + onEditRelationshipClick: (rId: string) => void; + onDeleteRelationshipClick: (rId: string) => void; +}; + +export const RelationshipsSection: React.FunctionComponent< + RelationshipsSectionProps +> = ({ + relationships, + emptyMessage, + onCreateNewRelationshipClick, + onEditRelationshipClick, + onDeleteRelationshipClick, +}) => { + return ( + + Relationships  + {relationships.length} + + + } + > +
+ {!relationships.length ? ( +
{emptyMessage}
+ ) : ( +
    + {relationships.map((r) => { + const relationshipLabel = getDefaultRelationshipName( + r.relationship + ); + + return ( +
  • + + {relationshipLabel} + + { + onEditRelationshipClick(r.id); + }} + > + + + { + onDeleteRelationshipClick(r.id); + }} + > + + +
  • + ); + })} +
+ )} +
+
+ ); +}; 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..1765f514feb 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 { @@ -28,15 +29,22 @@ 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/schema-traversal'; 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 +70,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 +118,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 +138,7 @@ export type DiagramActions = | RedoEditAction | CollectionSelectedAction | RelationSelectedAction + | FieldSelectedAction | DiagramBackgroundSelectedAction; const INITIAL_STATE: DiagramState = null; @@ -280,6 +296,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,16 +404,34 @@ 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, }; } -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( @@ -399,8 +443,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, }, @@ -876,31 +920,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; } @@ -910,7 +937,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 0d1b98e5c27..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 ( @@ -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 30dbf336402..d8f800b0a52 100644 --- a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -6,8 +6,11 @@ import type { MongoDBJSONSchema } from 'mongodb-schema'; import type { SelectedItems } from '../store/diagram'; import type { DataModelCollection, + FieldPath, Relationship, } from '../services/data-model-storage'; +import { traverseSchema } from './schema-traversal'; +import { areFieldPathsEqual } from './utils'; function getBsonTypeName(bsonType: string) { switch (bsonType) { @@ -51,7 +54,7 @@ function getFieldTypeDisplay(bsonTypes: string[]) { ); } -export const getSelectedFields = ( +export const getHighlightedFields = ( selectedItems: SelectedItems | null, relationships?: Relationship[] ): Record => { @@ -71,73 +74,44 @@ export const getSelectedFields = ( return selection; }; -export const getFieldsFromSchema = ( - jsonSchema: MongoDBJSONSchema, - highlightedFields: string[][] = [], - depth = 0 -): NodeProps['fields'] => { +export const getFieldsFromSchema = ({ + jsonSchema, + highlightedFields = [], + selectedField, +}: { + jsonSchema: MongoDBJSONSchema; + highlightedFields?: FieldPath[]; + selectedField?: FieldPath; +}): NodeProps['fields'] => { if (!jsonSchema || !jsonSchema.properties) { 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 - // 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 fields: NodeProps['fields'] = []; - fields.push({ - name, - type: getFieldTypeDisplay(types.flat()), - depth: depth, - glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [], - variant: - highlightedFields.length && - highlightedFields.some( - (field) => field.length === 1 && field[0] === name - ) - ? 'preview' - : undefined, - }); - - if (children.length > 0) { - fields = [ - ...fields, - ...children.flat().flatMap((child) => - getFieldsFromSchema( - child, - highlightedFields - .filter((field) => field[0] === name) - .map((field) => field.slice(1)), - depth + 1 + traverseSchema({ + jsonSchema, + visitor: ({ fieldPath, fieldTypes }) => { + fields.push({ + name: fieldPath[fieldPath.length - 1], + id: fieldPath, + type: getFieldTypeDisplay(fieldTypes), + depth: fieldPath.length - 1, + glyphs: + fieldTypes.length === 1 && fieldTypes[0] === 'objectId' + ? ['key'] + : [], + selectable: true, + selected: areFieldPathsEqual(fieldPath, selectedField ?? []), + variant: + highlightedFields.length && + highlightedFields.some((highlightedField) => + areFieldPathsEqual(fieldPath, highlightedField) ) - ), - ]; - } - } + ? 'preview' + : undefined, + }); + }, + }); return fields; }; @@ -145,13 +119,15 @@ 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; @@ -164,11 +140,11 @@ 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, + }), selected, connectable: isInRelationshipDrawingMode, draggable: !isInRelationshipDrawingMode, 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..ff3dbf4fe8a --- /dev/null +++ b/packages/compass-data-modeling/src/utils/schema-traversal.spec.tsx @@ -0,0 +1,454 @@ +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 () { + const result = getFieldFromSchema({ + fieldPath: ['name'], + jsonSchema: {}, + }); + expect(result).to.be.undefined; + }); + + it('wrong path', function () { + 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' }, + }, + }, + }, + }, + }); + expect(result).to.be.undefined; + }); + }); + + 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' }, + }); + }); + }); +}); 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..ae6db3abd10 --- /dev/null +++ b/packages/compass-data-modeling/src/utils/schema-traversal.tsx @@ -0,0 +1,167 @@ +import type { MongoDBJSONSchema } from 'mongodb-schema'; +import type { FieldPath } from '../services/data-model-storage'; + +/** + * Traverses a MongoDB JSON schema and calls the visitor for each field. + */ +export const traverseSchema = ({ + jsonSchema, + visitor, + parentFieldPath = [], +}: { + jsonSchema: MongoDBJSONSchema; + visitor: ({ + fieldPath, + fieldTypes, + }: { + fieldPath: FieldPath; + fieldTypes: string[]; + fieldSchema: MongoDBJSONSchema; + }) => 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(), + fieldSchema: field, + }); + + 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. + */ +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) { + return; + } + + // 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, + }; + } + // Continue searching in the next step + return getFieldFromSchema({ + jsonSchema: nextStep, + fieldPath: remainingFieldPath, + parentFieldPath: [...parentFieldPath, nextInPath], + }); +}; 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); +} diff --git a/packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json b/packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json index e76aa28a2bc..b205fec74ae 100644 --- a/packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json +++ b/packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json @@ -199,10 +199,15 @@ "bsonType": "objectId" }, "airline": { - "bsonType": "string" - }, - "airline_id": { - "bsonType": "string" + "bsonType": "object", + "properties": { + "name": { + "bsonType": "string" + }, + "id": { + "bsonType": "string" + } + } }, "codeshare": { "bsonType": "string" 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..0d7bc9d8338 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: 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 })