diff --git a/packages/compass-data-modeling/src/components/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/collection-drawer-content.tsx new file mode 100644 index 00000000000..60bc33e9875 --- /dev/null +++ b/packages/compass-data-modeling/src/components/collection-drawer-content.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import type { Relationship } from '../services/data-model-storage'; +import { Button, H3 } from '@mongodb-js/compass-components'; +import { + createNewRelationship, + deleteRelationship, + getCurrentDiagramFromState, + selectCurrentModel, + selectRelationship, +} from '../store/diagram'; +import type { DataModelingState } from '../store/reducer'; + +type CollectionDrawerContentProps = { + namespace: string; + relationships: Relationship[]; + shouldShowRelationshipEditingForm?: boolean; + onCreateNewRelationshipClick: (namespace: string) => void; + onEditRelationshipClick: (rId: string) => void; + onDeleteRelationshipClick: (rId: string) => void; +}; + +const CollectionDrawerContent: React.FunctionComponent< + CollectionDrawerContentProps +> = ({ + namespace, + relationships, + onCreateNewRelationshipClick, + onEditRelationshipClick, + onDeleteRelationshipClick, +}) => { + return ( + <> +

{namespace}

+ + + + ); +}; + +export default connect( + (state: DataModelingState, ownProps: { namespace: string }) => { + return { + relationships: selectCurrentModel( + getCurrentDiagramFromState(state).edits + ).relationships.filter((r) => { + const [local, foreign] = r.relationship; + return ( + local.ns === ownProps.namespace || foreign.ns === ownProps.namespace + ); + }), + }; + }, + { + onCreateNewRelationshipClick: createNewRelationship, + onEditRelationshipClick: selectRelationship, + onDeleteRelationshipClick: deleteRelationship, + } +)(CollectionDrawerContent); diff --git a/packages/compass-data-modeling/src/components/data-modeling.tsx b/packages/compass-data-modeling/src/components/data-modeling.tsx index 431c02c6756..a6eac583895 100644 --- a/packages/compass-data-modeling/src/components/data-modeling.tsx +++ b/packages/compass-data-modeling/src/components/data-modeling.tsx @@ -6,11 +6,12 @@ import NewDiagramFormModal from './new-diagram-form'; import type { DataModelingState } from '../store/reducer'; import { DiagramProvider } from '@mongodb-js/diagramming'; import DiagramEditorSidePanel from './diagram-editor-side-panel'; -type DataModelingPluginInitialProps = { + +type DataModelingProps = { showList: boolean; }; -const DataModeling: React.FunctionComponent = ({ +const DataModeling: React.FunctionComponent = ({ showList, }) => { return ( diff --git a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx new file mode 100644 index 00000000000..dcc8e8b32ad --- /dev/null +++ b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { expect } from 'chai'; +import { + createPluginTestHelpers, + screen, + waitFor, + userEvent, + within, +} from '@mongodb-js/testing-library-compass'; +import { DataModelingWorkspaceTab } from '../index'; +import DiagramEditorSidePanel from './diagram-editor-side-panel'; +import { + getCurrentDiagramFromState, + openDiagram, + selectCollection, + selectCurrentModel, + selectRelationship, +} from '../store/diagram'; +import dataModel from '../../test/fixtures/data-model-with-relationships.json'; +import type { MongoDBDataModelDescription } from '../services/data-model-storage'; + +async function comboboxSelectItem( + label: string, + value: string, + visibleLabel = value +) { + userEvent.click(screen.getByRole('textbox', { name: label })); + await waitFor(() => { + screen.getByRole('option', { name: visibleLabel }); + }); + userEvent.click(screen.getByRole('option', { name: visibleLabel })); + await waitFor(() => { + expect(screen.getByRole('textbox', { name: label })).to.have.attribute( + 'value', + value + ); + }); +} + +describe('DiagramEditorSidePanel', function () { + function renderDrawer() { + const { renderWithConnections } = createPluginTestHelpers( + DataModelingWorkspaceTab.provider.withMockServices({}) + ); + const result = renderWithConnections( + + ); + result.plugin.store.dispatch( + openDiagram(dataModel as MongoDBDataModelDescription) + ); + return result; + } + + it('should not render if no items are selected', function () { + renderDrawer(); + expect(screen.queryByTestId('data-modeling-drawer')).to.eq(null); + }); + + it('should render a collection context drawer when collection is clicked', function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectCollection('flights.airlines')); + expect(screen.getByText('flights.airlines')).to.be.visible; + }); + + it('should render a relationship context drawer when relations is clicked', function () { + const result = renderDrawer(); + result.plugin.store.dispatch( + selectRelationship('204b1fc0-601f-4d62-bba3-38fade71e049') + ); + expect(screen.getByText('Edit Relationship')).to.be.visible; + expect( + document.querySelector( + '[data-relationship-id="204b1fc0-601f-4d62-bba3-38fade71e049"]' + ) + ).to.be.visible; + }); + + it('should change the content of the drawer when selecting different items', function () { + const result = renderDrawer(); + + result.plugin.store.dispatch(selectCollection('flights.airlines')); + expect(screen.getByText('flights.airlines')).to.be.visible; + + result.plugin.store.dispatch( + selectCollection('flights.airports_coordinates_for_schema') + ); + expect(screen.getByText('flights.airports_coordinates_for_schema')).to.be + .visible; + + result.plugin.store.dispatch( + selectRelationship('204b1fc0-601f-4d62-bba3-38fade71e049') + ); + expect( + document.querySelector( + '[data-relationship-id="204b1fc0-601f-4d62-bba3-38fade71e049"]' + ) + ).to.be.visible; + + result.plugin.store.dispatch( + selectRelationship('6f776467-4c98-476b-9b71-1f8a724e6c2c') + ); + expect( + document.querySelector( + '[data-relationship-id="6f776467-4c98-476b-9b71-1f8a724e6c2c"]' + ) + ).to.be.visible; + + result.plugin.store.dispatch(selectCollection('flights.planes')); + expect(screen.getByText('flights.planes')).to.be.visible; + }); + + it('should open and edit relationship starting from collection', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(selectCollection('flights.countries')); + + // Open relationshipt editing form + const relationshipCard = document.querySelector( + '[data-relationship-id="204b1fc0-601f-4d62-bba3-38fade71e049"]' + ); + userEvent.click( + within(relationshipCard!).getByRole('button', { name: 'Edit' }) + ); + expect(screen.getByText('Edit Relationship')).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'); + + // 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 = selectCurrentModel( + getCurrentDiagramFromState(result.plugin.store.getState()).edits + ).relationships.find((r) => { + 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: 1, + }, + ]); + }); +}); diff --git a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx index d289777860e..956d3f3e1a8 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx @@ -1,26 +1,20 @@ import React from 'react'; import { connect } from 'react-redux'; import type { DataModelingState } from '../store/reducer'; -import { closeSidePanel } from '../store/side-panel'; import { Button, css, cx, - Body, - spacing, palette, useDarkMode, } from '@mongodb-js/compass-components'; +import CollectionDrawerContent from './collection-drawer-content'; +import RelationshipDrawerContent from './relationship-drawer-content'; +import { closeDrawer } from '../store/diagram'; const containerStyles = css({ width: '400px', height: '100%', - - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: spacing[400], borderLeft: `1px solid ${palette.gray.light2}`, }); @@ -29,21 +23,42 @@ const darkModeContainerStyles = css({ }); type DiagramEditorSidePanelProps = { - isOpen: boolean; + selectedItems: { type: 'relationship' | 'collection'; id: string } | null; onClose: () => void; }; function DiagmramEditorSidePanel({ - isOpen, + selectedItems, onClose, }: DiagramEditorSidePanelProps) { const isDarkMode = useDarkMode(); - if (!isOpen) { + + if (!selectedItems) { return null; } + + let content; + + if (selectedItems.type === 'collection') { + content = ( + + ); + } else if (selectedItems.type === 'relationship') { + content = ( + + ); + } + return ( -
- This feature is under development. +
+ {content} @@ -53,12 +68,11 @@ function DiagmramEditorSidePanel({ export default connect( (state: DataModelingState) => { - const { sidePanel } = state; return { - isOpen: sidePanel.isOpen, + selectedItems: state.diagram?.selectedItems ?? null, }; }, { - onClose: closeSidePanel, + onClose: closeDrawer, } )(DiagmramEditorSidePanel); diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 31e6148871e..a3f1b43652e 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -14,6 +14,9 @@ import { moveCollection, getCurrentDiagramFromState, selectCurrentModel, + selectCollection, + selectRelationship, + selectBackground, } from '../store/diagram'; import { Banner, @@ -38,7 +41,6 @@ import type { StaticModel } from '../services/data-model-storage'; import DiagramEditorToolbar from './diagram-editor-toolbar'; import ExportDiagramModal from './export-diagram-modal'; import { useLogger } from '@mongodb-js/compass-logging/provider'; -import { openSidePanel } from '../store/side-panel'; const loadingContainerStyles = css({ width: '100%', @@ -189,7 +191,10 @@ const DiagramEditor: React.FunctionComponent<{ onCancelClick: () => void; onApplyInitialLayout: (positions: Record) => void; onMoveCollection: (ns: string, newPosition: [number, number]) => void; - onOpenSidePanel: () => void; + onCollectionSelect: (namespace: string) => void; + onRelationshipSelect: (rId: string) => void; + onDiagramBackgroundClicked: () => void; + selectedItems: { type: 'relationship' | 'collection'; id: string } | null; }> = ({ diagramLabel, step, @@ -198,7 +203,10 @@ const DiagramEditor: React.FunctionComponent<{ onCancelClick, onApplyInitialLayout, onMoveCollection, - onOpenSidePanel, + onCollectionSelect, + onRelationshipSelect, + onDiagramBackgroundClicked, + selectedItems, }) => { const { log, mongoLogId } = useLogger('COMPASS-DATA-MODELING-DIAGRAM-EDITOR'); const isDarkMode = useDarkMode(); @@ -222,13 +230,17 @@ const DiagramEditor: React.FunctionComponent<{ const [source, target] = relationship.relationship; return { id: relationship.id, - source: source.ns, - target: target.ns, + source: source.ns ?? '', + target: target.ns ?? '', markerStart: source.cardinality === 1 ? 'one' : 'many', markerEnd: target.cardinality === 1 ? 'one' : 'many', + selected: + !!selectedItems && + selectedItems.type === 'relationship' && + selectedItems.id === relationship.id, }; }); - }, [model?.relationships]); + }, [model?.relationships, selectedItems]); const nodes = useMemo(() => { return (model?.collections ?? []).map( @@ -241,9 +253,13 @@ const DiagramEditor: React.FunctionComponent<{ }, title: toNS(coll.ns).collection, fields: getFieldsFromSchema(coll.jsonSchema), + selected: + !!selectedItems && + selectedItems.type === 'collection' && + selectedItems.id === coll.ns, }) ); - }, [model?.collections]); + }, [model?.collections, selectedItems]); const applyInitialLayout = useCallback(async () => { try { @@ -335,9 +351,20 @@ const DiagramEditor: React.FunctionComponent<{ title={diagramLabel} edges={edges} nodes={areNodesReady ? nodes : []} - onEdgeClick={() => { - // TODO: we have to open a side panel with edge details - onOpenSidePanel(); + // With threshold too low clicking sometimes gets confused with + // dragging + // @ts-expect-error expose this prop from the component + nodeDragThreshold={3} + // @ts-expect-error expose this prop from the component + onNodeClick={(_evt, node) => { + if (node.type !== 'collection') { + return; + } + onCollectionSelect(node.id); + }} + onPaneClick={onDiagramBackgroundClicked} + onEdgeClick={(_evt, edge) => { + onRelationshipSelect(edge.id); }} fitViewOptions={{ maxZoom: 1, @@ -366,10 +393,11 @@ export default connect( return { step: step, model: diagram - ? selectCurrentModel(getCurrentDiagramFromState(state)) + ? selectCurrentModel(getCurrentDiagramFromState(state).edits) : null, editErrors: diagram?.editErrors, diagramLabel: diagram?.name || 'Schema Preview', + selectedItems: state.diagram?.selectedItems ?? null, }; }, { @@ -377,6 +405,8 @@ export default connect( onCancelClick: cancelAnalysis, onApplyInitialLayout: applyInitialLayout, onMoveCollection: moveCollection, - onOpenSidePanel: openSidePanel, + onCollectionSelect: selectCollection, + onRelationshipSelect: selectRelationship, + onDiagramBackgroundClicked: selectBackground, } )(DiagramEditor); diff --git a/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx b/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx new file mode 100644 index 00000000000..3ed64961f80 --- /dev/null +++ b/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx @@ -0,0 +1,309 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { connect } from 'react-redux'; +import type { DataModelingState } from '../store/reducer'; +import { + Button, + Combobox, + FormFieldContainer, + H3, + ComboboxOption, + Select, + Option, +} from '@mongodb-js/compass-components'; +import { + deleteRelationship, + getCurrentDiagramFromState, + getRelationshipForCurrentModel, + selectFieldsForCurrentModel, + updateRelationship, +} from '../store/diagram'; +import toNS from 'mongodb-ns'; +import type { Relationship } from '../services/data-model-storage'; +import { cloneDeep } from 'lodash'; + +type RelationshipDrawerContentProps = { + relationshipId: string; + relationship: Relationship; + fields: Record; + onRelationshipUpdate: (relationship: Relationship) => void; + onDeleteRelationshipClick: (rId: string) => void; +}; + +type RelationshipFormFields = { + localCollection: string; + localField: string; + foreignCollection: string; + foreignField: string; + localCardinality: string; + foreignCardinality: string; +}; + +const FIELD_DIVIDER = '~~##$$##~~'; + +function useRelationshipFormFields( + relationship: Relationship, + onRelationshipChange: (relationship: Relationship) => void +): RelationshipFormFields & { + onFieldChange: (key: keyof RelationshipFormFields, value: string) => void; +} { + const onRelationshipChangeRef = useRef(onRelationshipChange); + onRelationshipChangeRef.current = onRelationshipChange; + const [local, foreign] = relationship.relationship; + const localCollection = local.ns ?? ''; + // Leafygreen select / combobox only supports string fields, so we stringify + // the value for the form, and then will convert it back on update + const localField = local.fields?.join(FIELD_DIVIDER) ?? ''; + const localCardinality = String(local.cardinality); + const foreignCollection = foreign.ns ?? ''; + const foreignField = foreign.fields?.join(FIELD_DIVIDER) ?? ''; + const foreignCardinality = String(foreign.cardinality); + const onFieldChange = useCallback( + (key: keyof RelationshipFormFields, value: string) => { + const newRelationship = cloneDeep(relationship); + switch (key) { + case 'localCollection': + newRelationship.relationship[0].ns = value; + newRelationship.relationship[0].fields = null; + break; + case 'localField': + newRelationship.relationship[0].fields = value.split(FIELD_DIVIDER); + break; + case 'localCardinality': + newRelationship.relationship[0].cardinality = Number(value); + break; + case 'foreignCollection': + newRelationship.relationship[1].ns = value; + newRelationship.relationship[1].fields = null; + break; + case 'foreignField': + newRelationship.relationship[1].fields = value.split(FIELD_DIVIDER); + break; + case 'foreignCardinality': + newRelationship.relationship[1].cardinality = Number(value); + break; + } + onRelationshipChangeRef.current(newRelationship); + }, + [relationship] + ); + return { + localCollection, + localField, + localCardinality, + foreignCollection, + foreignField, + foreignCardinality, + onFieldChange, + }; +} + +const CARDINALITY_OPTIONS = [1, 10, 100, 1000]; + +const RelationshipDrawerContent: React.FunctionComponent< + RelationshipDrawerContentProps +> = ({ + relationshipId, + relationship, + fields, + onRelationshipUpdate, + onDeleteRelationshipClick, +}) => { + const collections = useMemo(() => { + return Object.keys(fields); + }, [fields]); + + const { + localCollection, + localField, + localCardinality, + foreignCollection, + foreignField, + foreignCardinality, + onFieldChange, + } = useRelationshipFormFields(relationship, onRelationshipUpdate); + + const localFieldOptions = useMemo(() => { + return fields[localCollection] ?? []; + }, [fields, localCollection]); + + const foreignFieldOptions = useMemo(() => { + return fields[foreignCollection] ?? []; + }, [fields, foreignCollection]); + + return ( +
+

Edit Relationship

+ + + { + if (val) { + onFieldChange('localCollection', val); + } + }} + multiselect={false} + clearable={false} + > + {collections.map((ns) => { + return ( + + ); + })} + + + + + { + if (val) { + onFieldChange('localField', val); + } + }} + multiselect={false} + clearable={false} + > + {localFieldOptions.map((field) => { + return ( + + ); + })} + + + + + { + if (val) { + onFieldChange('foreignCollection', val); + } + }} + multiselect={false} + clearable={false} + > + {collections.map((ns) => { + return ( + + ); + })} + + + + + { + if (val) { + onFieldChange('foreignField', val); + } + }} + multiselect={false} + clearable={false} + > + {foreignFieldOptions.map((field) => { + return ( + + ); + })} + + + + + + + + + + + + + + +
+ ); +}; + +export default connect( + (state: DataModelingState, ownProps: { relationshipId: string }) => { + const diagram = getCurrentDiagramFromState(state); + const relationship = getRelationshipForCurrentModel( + diagram.edits, + ownProps.relationshipId + ); + if (!relationship) { + throw new Error( + `Can not find relationship with ${ownProps.relationshipId}` + ); + } + return { + relationship, + fields: selectFieldsForCurrentModel(diagram.edits), + }; + }, + { + onRelationshipUpdate: updateRelationship, + onDeleteRelationshipClick: deleteRelationship, + } +)(RelationshipDrawerContent); diff --git a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx b/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx index 821c3bcd126..9af9ba67429 100644 --- a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx +++ b/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx @@ -16,7 +16,7 @@ import { import { useDataModelSavedItems } from '../provider'; import { deleteDiagram, - getCurrentModel, + selectCurrentModel, openDiagram, renameDiagram, } from '../store/diagram'; @@ -185,7 +185,9 @@ export const SavedDiagramsList: React.FunctionComponent<{ >(() => { return items.map((item) => { const databases = new Set( - getCurrentModel(item).collections.map(({ ns }) => toNS(ns).database) + selectCurrentModel(item.edits).collections.map( + ({ ns }) => toNS(ns).database + ) ); return { ...item, 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 7134c94c6d6..0f93708bb5f 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -2,9 +2,9 @@ import { z } from '@mongodb-js/compass-user-data'; import type { MongoDBJSONSchema } from 'mongodb-schema'; export const RelationshipSideSchema = z.object({ - ns: z.string(), + ns: z.string().nullable(), cardinality: z.number(), - fields: z.array(z.string()), + fields: z.array(z.string()).nullable(), }); export type RelationshipSide = z.output; @@ -51,6 +51,10 @@ const EditSchemaVariants = z.discriminatedUnion('type', [ type: z.literal('AddRelationship'), relationship: RelationshipSchema, }), + z.object({ + type: z.literal('UpdateRelationship'), + relationship: RelationshipSchema, + }), z.object({ type: z.literal('RemoveRelationship'), relationshipId: z.string().uuid(), @@ -66,6 +70,8 @@ export const EditSchema = z.intersection(EditSchemaBase, EditSchemaVariants); export type Edit = z.output; +export type EditAction = z.output; + export const validateEdit = ( edit: unknown ): { result: true; errors?: never } | { result: false; errors: string[] } => { diff --git a/packages/compass-data-modeling/src/store/diagram.spec.ts b/packages/compass-data-modeling/src/store/diagram.spec.ts index 5b3e1b87592..08ff588a9c3 100644 --- a/packages/compass-data-modeling/src/store/diagram.spec.ts +++ b/packages/compass-data-modeling/src/store/diagram.spec.ts @@ -220,7 +220,7 @@ describe('Data Modeling store', function () { relationship: newRelationship, }); - const currentModel = getCurrentModel(diagram); + const currentModel = getCurrentModel(diagram.edits); expect(currentModel.relationships).to.have.length(2); }); @@ -265,7 +265,7 @@ describe('Data Modeling store', function () { expect(diagram.edits[0]).to.deep.equal(loadedDiagram.edits[0]); expect(diagram.edits[1]).to.deep.include(edit); - const currentModel = getCurrentModel(diagram); + const currentModel = getCurrentModel(diagram.edits); expect(currentModel.collections[0].displayPosition).to.deep.equal([ 100, 100, ]); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index da488b2389d..a34eb73611c 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -1,6 +1,7 @@ import type { Reducer } from 'redux'; import { UUID } from 'bson'; import { isAction } from './util'; +import type { EditAction, Relationship } from '../services/data-model-storage'; import { validateEdit, type Edit, @@ -11,6 +12,7 @@ import { AnalysisProcessActionTypes } from './analysis-process'; import { memoize } from 'lodash'; import type { DataModelingState, DataModelingThunkAction } from './reducer'; import { showConfirmation, showPrompt } from '@mongodb-js/compass-components'; +import type { MongoDBJSONSchema } from 'mongodb-schema'; import { downloadDiagram } from '../services/open-and-download-diagram'; function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { @@ -25,6 +27,7 @@ export type DiagramState = next: Edit[][]; }; editErrors?: string[]; + selectedItems: { type: 'collection' | 'relationship'; id: string } | null; }) | null; // null when no diagram is currently open @@ -37,6 +40,10 @@ export enum DiagramActionTypes { APPLY_EDIT_FAILED = 'data-modeling/diagram/APPLY_EDIT_FAILED', UNDO_EDIT = 'data-modeling/diagram/UNDO_EDIT', REDO_EDIT = 'data-modeling/diagram/REDO_EDIT', + COLLECTION_SELECTED = 'data-modeling/diagram/COLLECTION_SELECTED', + RELATIONSHIP_SELECTED = 'data-modeling/diagram/RELATIONSHIP_SELECTED', + DIAGRAM_BACKGROUND_SELECTED = 'data-modeling/diagram/DIAGRAM_BACKGROUND_SELECTED', + DRAWER_CLOSED = 'data-modeling/diagram/DRAWER_CLOSED', } export type OpenDiagramAction = { @@ -78,6 +85,24 @@ export type RedoEditAction = { type: DiagramActionTypes.REDO_EDIT; }; +export type CollectionSelectedAction = { + type: DiagramActionTypes.COLLECTION_SELECTED; + namespace: string; +}; + +export type RelationSelectedAction = { + type: DiagramActionTypes.RELATIONSHIP_SELECTED; + relationshipId: string; +}; + +export type DiagramBackgroundSelectedAction = { + type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED; +}; + +export type DrawerClosedAction = { + type: DiagramActionTypes.DRAWER_CLOSED; +}; + export type DiagramActions = | OpenDiagramAction | DeleteDiagramAction @@ -86,7 +111,11 @@ export type DiagramActions = | ApplyEditAction | ApplyEditFailedAction | UndoEditAction - | RedoEditAction; + | RedoEditAction + | CollectionSelectedAction + | RelationSelectedAction + | DiagramBackgroundSelectedAction + | DrawerClosedAction; const INITIAL_STATE: DiagramState = null; @@ -105,6 +134,7 @@ export const diagramReducer: Reducer = ( current, next: [], }, + selectedItems: null, }; } @@ -137,6 +167,7 @@ export const diagramReducer: Reducer = ( ], next: [], }, + selectedItems: null, }; } @@ -177,16 +208,26 @@ export const diagramReducer: Reducer = ( }; } if (isAction(action, DiagramActionTypes.APPLY_EDIT)) { - return { + const newState = { ...state, edits: { prev: [...state.edits.prev, state.edits.current], - current: [...state.edits.current, action.edit], + current: [...state.edits.current, action.edit] as [Edit, ...Edit[]], next: [], }, editErrors: undefined, updatedAt: new Date().toISOString(), }; + + if ( + action.edit.type === 'RemoveRelationship' && + state.selectedItems?.type === 'relationship' && + state.selectedItems.id === action.edit.relationshipId + ) { + newState.selectedItems = null; + } + + return newState; } if (isAction(action, DiagramActionTypes.APPLY_EDIT_FAILED)) { return { @@ -224,9 +265,77 @@ export const diagramReducer: Reducer = ( updatedAt: new Date().toISOString(), }; } + if (isAction(action, DiagramActionTypes.COLLECTION_SELECTED)) { + return { + ...state, + selectedItems: { type: 'collection', id: action.namespace }, + }; + } + if (isAction(action, DiagramActionTypes.RELATIONSHIP_SELECTED)) { + return { + ...state, + selectedItems: { + type: 'relationship', + id: action.relationshipId, + }, + }; + } + if ( + isAction(action, DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED) || + isAction(action, DiagramActionTypes.DRAWER_CLOSED) + ) { + return { + ...state, + selectedItems: null, + }; + } return state; }; +export function selectCollection(namespace: string): CollectionSelectedAction { + return { type: DiagramActionTypes.COLLECTION_SELECTED, namespace }; +} + +export function selectRelationship( + relationshipId: string +): RelationSelectedAction { + return { + type: DiagramActionTypes.RELATIONSHIP_SELECTED, + relationshipId, + }; +} + +export function selectBackground(): DiagramBackgroundSelectedAction { + return { + type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED, + }; +} + +export function createNewRelationship( + namespace: string +): DataModelingThunkAction { + return (dispatch) => { + const relationshipId = new UUID().toString(); + dispatch( + applyEdit({ + type: 'AddRelationship', + relationship: { + id: relationshipId, + relationship: [ + { ns: namespace, cardinality: 1, fields: null }, + { ns: null, cardinality: 1, fields: null }, + ], + isInferred: false, + }, + }) + ); + dispatch({ + type: DiagramActionTypes.RELATIONSHIP_SELECTED, + relationshipId, + }); + }; +} + export function undoEdit(): DataModelingThunkAction { return (dispatch, getState, { dataModelStorage }) => { dispatch({ type: DiagramActionTypes.UNDO_EDIT }); @@ -257,28 +366,28 @@ export function moveCollection( } export function applyEdit( - rawEdit: Omit -): DataModelingThunkAction { + rawEdit: EditAction +): DataModelingThunkAction { return (dispatch, getState, { dataModelStorage }) => { const edit = { ...rawEdit, id: new UUID().toString(), timestamp: new Date().toISOString(), - // TS has a problem recognizing the discriminated union - } as Edit; + }; const { result: isValid, errors } = validateEdit(edit); if (!isValid) { dispatch({ type: DiagramActionTypes.APPLY_EDIT_FAILED, errors, }); - return; + return isValid; } dispatch({ type: DiagramActionTypes.APPLY_EDIT, edit, }); void dataModelStorage.save(getCurrentDiagramFromState(getState())); + return isValid; }; } @@ -351,6 +460,23 @@ export function renameDiagram( }; } +export function updateRelationship( + relationship: Relationship +): DataModelingThunkAction { + return applyEdit({ + type: 'UpdateRelationship', + relationship, + }); +} + +export function deleteRelationship(relationshipId: string) { + return applyEdit({ type: 'RemoveRelationship', relationshipId }); +} + +export function closeDrawer(): DrawerClosedAction { + return { type: DiagramActionTypes.DRAWER_CLOSED }; +} + function _applyEdit(edit: Edit, model?: StaticModel): StaticModel { if (edit.type === 'SetModel') { return edit.model; @@ -373,6 +499,20 @@ function _applyEdit(edit: Edit, model?: StaticModel): StaticModel { ), }; } + case 'UpdateRelationship': { + const existingRelationship = model.relationships.find((r) => { + return r.id === edit.relationship.id; + }); + if (!existingRelationship) { + throw new Error('Can not update non-existent relationship'); + } + return { + ...model, + relationships: model.relationships.map((r) => { + return r === existingRelationship ? edit.relationship : r; + }), + }; + } case 'MoveCollection': { return { ...model, @@ -393,11 +533,15 @@ function _applyEdit(edit: Edit, model?: StaticModel): StaticModel { } } +/** + * @internal Exported for testing purposes only, use `selectCurrentModel` + * instead + */ export function getCurrentModel( - description: MongoDBDataModelDescription + edits: MongoDBDataModelDescription['edits'] ): StaticModel { // Get the last 'SetModel' edit. - const reversedSetModelEditIndex = description.edits + const reversedSetModelEditIndex = edits .slice() .reverse() .findIndex((edit) => edit.type === 'SetModel'); @@ -406,19 +550,18 @@ export function getCurrentModel( } // Calculate the actual index in the original array. - const lastSetModelEditIndex = - description.edits.length - 1 - reversedSetModelEditIndex; + const lastSetModelEditIndex = edits.length - 1 - reversedSetModelEditIndex; // Start with the StaticModel from the last `SetModel` edit. - const lastSetModelEdit = description.edits[lastSetModelEditIndex]; + const lastSetModelEdit = edits[lastSetModelEditIndex]; if (lastSetModelEdit.type !== 'SetModel') { throw new Error('Something went wrong, last edit is not a SetModel'); } let currentModel = lastSetModelEdit.model; // Apply all subsequent edits after the last `SetModel` edit. - for (let i = lastSetModelEditIndex + 1; i < description.edits.length; i++) { - const edit = description.edits[i]; + for (let i = lastSetModelEditIndex + 1; i < edits.length; i++) { + const edit = edits[i]; currentModel = _applyEdit(edit, currentModel); } @@ -443,4 +586,45 @@ export function getCurrentDiagramFromState( return { id, connectionId, name, edits, createdAt, updatedAt }; } +/** + * Memoised method to return computed model + */ export const selectCurrentModel = memoize(getCurrentModel); + +function extractFields( + parentSchema: MongoDBJSONSchema, + parentKey?: string[], + fields: string[][] = [] +) { + 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); + } + } + return fields; +} + +function getFieldsForCurrentModel( + edits: MongoDBDataModelDescription['edits'] +): Record { + const model = selectCurrentModel(edits); + const fields = Object.fromEntries( + model.collections.map((collection) => { + return [collection.ns, extractFields(collection.jsonSchema)]; + }) + ); + return fields; +} + +export const selectFieldsForCurrentModel = memoize(getFieldsForCurrentModel); + +export function getRelationshipForCurrentModel( + edits: MongoDBDataModelDescription['edits'], + relationshipId: string +) { + return selectCurrentModel(edits).relationships.find( + (r) => r.id === relationshipId + ); +} diff --git a/packages/compass-data-modeling/src/store/export-diagram.ts b/packages/compass-data-modeling/src/store/export-diagram.ts index 27841c7d122..31e5034388c 100644 --- a/packages/compass-data-modeling/src/store/export-diagram.ts +++ b/packages/compass-data-modeling/src/store/export-diagram.ts @@ -120,7 +120,7 @@ export function exportDiagram( if (exportFormat === 'json') { const model = selectCurrentModel( - getCurrentDiagramFromState(getState()) + getCurrentDiagramFromState(getState()).edits ); exportToJson(diagram.name, model); } else if (exportFormat === 'png') { diff --git a/packages/compass-data-modeling/src/store/reducer.ts b/packages/compass-data-modeling/src/store/reducer.ts index b7b13d92c6f..d7de974b1b2 100644 --- a/packages/compass-data-modeling/src/store/reducer.ts +++ b/packages/compass-data-modeling/src/store/reducer.ts @@ -20,8 +20,6 @@ import type { ExportDiagramActions, } from './export-diagram'; import { exportDiagramReducer } from './export-diagram'; -import type { SidePanelActions, SidePanelActionTypes } from './side-panel'; -import { sidePanelReducer } from './side-panel'; const reducer = combineReducers({ step: stepReducer, @@ -29,21 +27,18 @@ const reducer = combineReducers({ analysisProgress: analysisProcessReducer, diagram: diagramReducer, exportDiagram: exportDiagramReducer, - sidePanel: sidePanelReducer, }); export type DataModelingActions = | GenerateDiagramWizardActions | AnalysisProgressActions | DiagramActions - | SidePanelActions | ExportDiagramActions; export type DataModelingActionTypes = | GenerateDiagramWizardActionTypes | AnalysisProcessActionTypes | DiagramActionTypes - | SidePanelActionTypes | ExportDiagramActionTypes; export type DataModelingState = ReturnType; diff --git a/packages/compass-data-modeling/src/store/side-panel.ts b/packages/compass-data-modeling/src/store/side-panel.ts deleted file mode 100644 index e4ed063d445..00000000000 --- a/packages/compass-data-modeling/src/store/side-panel.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Reducer } from 'redux'; -import { isAction } from './util'; - -export type SidePanelState = { - isOpen: boolean; -}; - -export enum SidePanelActionTypes { - SIDE_PANEL_OPENED = 'data-modeling/side-panel/SIDE_PANEL_OPENED', - SIDE_PANEL_CLOSED = 'data-modeling/side-panel/SIDE_PANEL_CLOSED', -} - -export type SidePanelOpenedAction = { - type: SidePanelActionTypes.SIDE_PANEL_OPENED; -}; - -export type SidePanelClosedAction = { - type: SidePanelActionTypes.SIDE_PANEL_CLOSED; -}; - -export type SidePanelActions = SidePanelOpenedAction | SidePanelClosedAction; - -const INITIAL_STATE: SidePanelState = { - isOpen: false, -}; - -export const sidePanelReducer: Reducer = ( - state = INITIAL_STATE, - action -) => { - if (isAction(action, SidePanelActionTypes.SIDE_PANEL_OPENED)) { - return { - ...state, - isOpen: true, - }; - } - if (isAction(action, SidePanelActionTypes.SIDE_PANEL_CLOSED)) { - return { - ...state, - isOpen: false, - }; - } - return state; -}; - -export const openSidePanel = (): SidePanelOpenedAction => ({ - type: SidePanelActionTypes.SIDE_PANEL_OPENED, -}); - -export const closeSidePanel = (): SidePanelClosedAction => ({ - type: SidePanelActionTypes.SIDE_PANEL_CLOSED, -}); 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 new file mode 100644 index 00000000000..5b6a15ad07d --- /dev/null +++ b/packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json @@ -0,0 +1,303 @@ +{ + "id": "26fea481-14a0-40de-aa8e-b3ef22afcf1b", + "connectionId": "108acc00-4d7b-4f56-be19-05c7288da71a", + "name": "Flights and countries", + "edits": [ + { + "id": "5e16572a-6978-4669-8103-e1f087b412cd", + "timestamp": "2025-06-20T06:35:26.773Z", + "type": "SetModel", + "model": { + "collections": [ + { + "ns": "flights.airlines", + "jsonSchema": { + "bsonType": "object", + "required": [ + "_id", + "active", + "airline", + "alias", + "base", + "country", + "iata", + "icao", + "name" + ], + "properties": { + "_id": { + "bsonType": "objectId" + }, + "active": { + "bsonType": "string" + }, + "airline": { + "bsonType": "int" + }, + "alias": { + "bsonType": ["string", "int"] + }, + "alliance": { + "bsonType": "string" + }, + "base": { + "bsonType": "string" + }, + "country": { + "bsonType": "string" + }, + "iata": { + "bsonType": "string" + }, + "icao": { + "bsonType": "string" + }, + "name": { + "bsonType": "string" + } + } + }, + "indexes": [], + "displayPosition": [144.04516098441445, 226.78180342288712] + }, + { + "ns": "flights.airports", + "jsonSchema": { + "bsonType": "object", + "required": [ + "_id", + "Altitude", + "Country", + "IATA", + "ICAO", + "Latitude", + "Longitude", + "Name" + ], + "properties": { + "_id": { + "bsonType": "int" + }, + "Altitude": { + "bsonType": "int" + }, + "City": { + "bsonType": "string" + }, + "Country": { + "bsonType": "string" + }, + "IATA": { + "bsonType": "string" + }, + "ICAO": { + "bsonType": "string" + }, + "Latitude": { + "bsonType": "double" + }, + "Longitude": { + "bsonType": "double" + }, + "Name": { + "bsonType": "string" + } + } + }, + "indexes": [], + "displayPosition": [157.74741328703078, 614.6105002761217] + }, + { + "ns": "flights.airports_coordinates_for_schema", + "jsonSchema": { + "bsonType": "object", + "required": ["_id", "coordinates", "Country", "Name"], + "properties": { + "_id": { + "bsonType": "int" + }, + "coordinates": { + "bsonType": "array", + "items": { + "bsonType": "double" + } + }, + "Country": { + "bsonType": "string" + }, + "Name": { + "bsonType": "string" + } + } + }, + "indexes": [], + "displayPosition": [611.3592580503537, 238.3680626820135] + }, + { + "ns": "flights.countries", + "jsonSchema": { + "bsonType": "object", + "required": ["_id", "iso_code", "name"], + "properties": { + "_id": { + "bsonType": "objectId" + }, + "dafif_code": { + "bsonType": "string" + }, + "iso_code": { + "bsonType": "string" + }, + "name": { + "bsonType": "string" + } + } + }, + "indexes": [], + "displayPosition": [156.9088146439409, 808.1350158017262] + }, + { + "ns": "flights.planes", + "jsonSchema": { + "bsonType": "object", + "required": ["_id", "IATA", "ICAO", "name"], + "properties": { + "_id": { + "bsonType": "objectId" + }, + "IATA": { + "bsonType": "string" + }, + "ICAO": { + "bsonType": "string" + }, + "name": { + "bsonType": "string" + } + } + }, + "indexes": [], + "displayPosition": [479.9432289278143, 650.1759375929954] + }, + { + "ns": "flights.routes", + "jsonSchema": { + "bsonType": "object", + "required": [ + "_id", + "airline", + "airline_id", + "destination_airport", + "destination_airport_id", + "equipment", + "source_airport", + "source_airport_id", + "stops" + ], + "properties": { + "_id": { + "bsonType": "objectId" + }, + "airline": { + "bsonType": "string" + }, + "airline_id": { + "bsonType": "string" + }, + "codeshare": { + "bsonType": "string" + }, + "destination_airport": { + "bsonType": "string" + }, + "destination_airport_id": { + "bsonType": "string" + }, + "equipment": { + "bsonType": "string" + }, + "source_airport": { + "bsonType": "string" + }, + "source_airport_id": { + "bsonType": "string" + }, + "stops": { + "bsonType": "int" + } + } + }, + "indexes": [], + "displayPosition": [853.3477815091105, 168.4596944341812] + } + ], + "relationships": [] + } + }, + { + "id": "cfba18e8-ffe6-4222-9c60-e063a31303b4", + "timestamp": "2025-06-20T06:36:04.745Z", + "type": "AddRelationship", + "relationship": { + "id": "6f776467-4c98-476b-9b71-1f8a724e6c2c", + "relationship": [ + { + "ns": "flights.airlines", + "cardinality": 1, + "fields": ["country"] + }, + { + "ns": "flights.countries", + "cardinality": 1, + "fields": ["name"] + } + ], + "isInferred": false + } + }, + { + "id": "74383587-5f0a-4b43-8eba-b810cc058c5b", + "timestamp": "2025-06-20T06:36:32.785Z", + "type": "AddRelationship", + "relationship": { + "id": "204b1fc0-601f-4d62-bba3-38fade71e049", + "relationship": [ + { + "ns": "flights.countries", + "cardinality": 1, + "fields": ["name"] + }, + { + "ns": "flights.airports", + "cardinality": 1, + "fields": ["Country"] + } + ], + "isInferred": false + } + }, + { + "type": "UpdateRelationship", + "relationship": { + "id": "6f776467-4c98-476b-9b71-1f8a724e6c2c", + "relationship": [ + { + "ns": "flights.airports_coordinates_for_schema", + "cardinality": 1, + "fields": ["coordinates"] + }, + { + "ns": "flights.countries", + "cardinality": 1, + "fields": ["name"] + } + ], + "isInferred": false + }, + "id": "6e06446c-3304-4a1d-a070-99ade7db2d4c", + "timestamp": "2025-07-17T10:09:00.490Z" + } + ], + "createdAt": "2025-06-20T06:35:26.773Z", + "updatedAt": "2025-07-17T12:07:37.740Z" +}