From 0eaeb31469c6d2d1278511e8b905b8b4d8b80fdc Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Thu, 17 Jul 2025 11:50:28 +0200 Subject: [PATCH 1/3] feat(data-modeling): node selection and relationship editing store and actions --- .../components/collection-drawer-content.tsx | 98 ++++++ .../components/diagram-editor-side-panel.tsx | 35 ++- .../src/components/diagram-editor.tsx | 41 ++- .../relationship-drawer-content.tsx | 227 ++++++++++++++ .../src/components/saved-diagrams-list.tsx | 6 +- .../src/services/data-model-storage.ts | 6 + .../src/store/diagram.spec.ts | 4 +- .../src/store/diagram.ts | 293 +++++++++++++++++- .../src/store/export-diagram.ts | 4 +- .../src/store/side-panel.ts | 254 +++++++++++++-- 10 files changed, 901 insertions(+), 67 deletions(-) create mode 100644 packages/compass-data-modeling/src/components/collection-drawer-content.tsx create mode 100644 packages/compass-data-modeling/src/components/relationship-drawer-content.tsx 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..7538578052c --- /dev/null +++ b/packages/compass-data-modeling/src/components/collection-drawer-content.tsx @@ -0,0 +1,98 @@ +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 { + deleteRelationship, + getCurrentDiagramFromState, + selectCurrentModel, +} from '../store/diagram'; +import type { DataModelingState } from '../store/reducer'; +import { + createNewRelationship, + startRelationshipEdit, +} from '../store/side-panel'; +import RelationshipDrawerContent from './relationship-drawer-content'; + +type CollectionDrawerContentProps = { + namespace: string; + relationships: Relationship[]; + shouldShowRelationshipEditingForm?: boolean; + onCreateNewRelationshipClick: (namespace: string) => void; + onEditRelationshipClick: (relationship: Relationship) => void; + onDeleteRelationshipClick: (rId: string) => void; +}; + +const CollectionDrawerContent: React.FunctionComponent< + CollectionDrawerContentProps +> = ({ + namespace, + relationships, + shouldShowRelationshipEditingForm, + onCreateNewRelationshipClick, + onEditRelationshipClick, + onDeleteRelationshipClick, +}) => { + if (shouldShowRelationshipEditingForm) { + return ; + } + + 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 + ); + }), + shouldShowRelationshipEditingForm: + state.sidePanel.viewType === 'relationship-editing', + }; + }, + { + onCreateNewRelationshipClick: createNewRelationship, + onEditRelationshipClick: startRelationshipEdit, + onDeleteRelationshipClick: deleteRelationship, + } +)(CollectionDrawerContent); 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..f2e70a713c1 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 @@ -6,21 +6,15 @@ 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'; 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,35 @@ 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,9 +61,8 @@ function DiagmramEditorSidePanel({ export default connect( (state: DataModelingState) => { - const { sidePanel } = state; return { - isOpen: sidePanel.isOpen, + selectedItems: state.diagram?.selectedItems ?? null, }; }, { diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 31e6148871e..75dd48878bd 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -14,6 +14,8 @@ import { moveCollection, getCurrentDiagramFromState, selectCurrentModel, + selectCollection, + selectRelationship, } from '../store/diagram'; import { Banner, @@ -38,7 +40,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 +190,9 @@ 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; + selectedItem?: string | null; }> = ({ diagramLabel, step, @@ -198,7 +201,9 @@ const DiagramEditor: React.FunctionComponent<{ onCancelClick, onApplyInitialLayout, onMoveCollection, - onOpenSidePanel, + onCollectionSelect, + onRelationshipSelect, + selectedItem, }) => { const { log, mongoLogId } = useLogger('COMPASS-DATA-MODELING-DIAGRAM-EDITOR'); const isDarkMode = useDarkMode(); @@ -226,9 +231,10 @@ const DiagramEditor: React.FunctionComponent<{ target: target.ns, markerStart: source.cardinality === 1 ? 'one' : 'many', markerEnd: target.cardinality === 1 ? 'one' : 'many', + selected: selectedItem === relationship.id, }; }); - }, [model?.relationships]); + }, [model?.relationships, selectedItem]); const nodes = useMemo(() => { return (model?.collections ?? []).map( @@ -241,9 +247,10 @@ const DiagramEditor: React.FunctionComponent<{ }, title: toNS(coll.ns).collection, fields: getFieldsFromSchema(coll.jsonSchema), + selected: selectedItem === coll.ns, }) ); - }, [model?.collections]); + }, [model?.collections, selectedItem]); const applyInitialLayout = useCallback(async () => { try { @@ -335,9 +342,19 @@ 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); + }} + onEdgeClick={(_evt, edge) => { + onRelationshipSelect(edge.id); }} fitViewOptions={{ maxZoom: 1, @@ -366,10 +383,13 @@ export default connect( return { step: step, model: diagram - ? selectCurrentModel(getCurrentDiagramFromState(state)) + ? selectCurrentModel(getCurrentDiagramFromState(state).edits) : null, editErrors: diagram?.editErrors, diagramLabel: diagram?.name || 'Schema Preview', + selectedItem: state.diagram?.selectedItems + ? state.diagram.selectedItems.id + : null, }; }, { @@ -377,6 +397,7 @@ export default connect( onCancelClick: cancelAnalysis, onApplyInitialLayout: applyInitialLayout, onMoveCollection: moveCollection, - onOpenSidePanel: openSidePanel, + onCollectionSelect: selectCollection, + onRelationshipSelect: selectRelationship, } )(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..14815041dc1 --- /dev/null +++ b/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx @@ -0,0 +1,227 @@ +import React, { useMemo } 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 type { RelationshipFormFields } from '../store/side-panel'; +import { + cancelRelationshipEditing, + changeRelationshipFormField, + submitRelationshipEdit, +} from '../store/side-panel'; +import { + deleteRelationship, + getCurrentDiagramFromState, + selectFieldsForCurrentModel, +} from '../store/diagram'; +import toNS from 'mongodb-ns'; + +type RelationshipDrawerContentProps = { + relationshipId: string; + localCollection: string; + localField: string; + foreignCollection: string; + foreignField: string; + cardinality: string; + fields: Record; + onFormFieldChange: (field: RelationshipFormFields, value: string) => void; + onDeleteRelationsClick: (rId: string) => void; + onSubmitFormClick: () => void; + onCancelEditClick: () => void; +}; + +const CARDINALITY_OPTIONS = [ + { label: 'One to one', value: 'one-to-one' }, + { label: 'One to many', value: 'one-to-many' }, + { label: 'Many to one', value: 'many-to-one' }, + { label: 'Many to many', value: 'many-to-many' }, +]; + +const RelationshipDrawerContent: React.FunctionComponent< + RelationshipDrawerContentProps +> = ({ + relationshipId, + localCollection, + localField, + foreignCollection, + foreignField, + cardinality, + fields, + onFormFieldChange, + onDeleteRelationsClick, + onSubmitFormClick, + onCancelEditClick, +}) => { + const collections = useMemo(() => { + return Object.keys(fields); + }, [fields]); + + const localFieldOptions = useMemo(() => { + return fields[localCollection] ?? []; + }, [fields, localCollection]); + + const foreignFieldOptions = useMemo(() => { + return fields[foreignCollection] ?? []; + }, [fields, foreignCollection]); + + const isValid = Boolean( + localCollection && + localField && + foreignCollection && + foreignField && + cardinality + ); + + return ( + <> +

Edit Relationship

+ + + { + if (val) { + onFormFieldChange('localCollection', val); + } + }} + multiselect={false} + clearable={false} + > + {collections.map((ns) => { + return ( + + ); + })} + + + + + { + if (val) { + onFormFieldChange('localField', val); + } + }} + multiselect={false} + clearable={false} + > + {localFieldOptions.map((field) => { + return ; + })} + + + + + { + if (val) { + onFormFieldChange('foreignCollection', val); + } + }} + multiselect={false} + clearable={false} + > + {collections.map((ns) => { + return ( + + ); + })} + + + + + { + if (val) { + onFormFieldChange('foreignField', val); + } + }} + multiselect={false} + clearable={false} + > + {foreignFieldOptions.map((field) => { + return ; + })} + + + + + + + +
+ + + +
+ + ); +}; + +export default connect( + (state: DataModelingState) => { + if (state.sidePanel.viewType !== 'relationship-editing') { + throw new Error('Unexpected state'); + } + return { + ...state.sidePanel.relationshipFormState, + fields: selectFieldsForCurrentModel( + getCurrentDiagramFromState(state).edits + ), + }; + }, + { + onFormFieldChange: changeRelationshipFormField, + onDeleteRelationsClick: deleteRelationship, + onSubmitFormClick: submitRelationshipEdit, + onCancelEditClick: cancelRelationshipEditing, + } +)(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..bdfba5cee73 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -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 f9071575a80..13afb0cd4cc 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,8 @@ import { AnalysisProcessActionTypes } from './analysis-process'; import { memoize } from 'lodash'; import type { DataModelingState, DataModelingThunkAction } from './reducer'; import { showConfirmation, showPrompt } from '@mongodb-js/compass-components'; +import { SidePanelActionTypes } from './side-panel'; +import type { MongoDBJSONSchema } from 'mongodb-schema'; function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { return Array.isArray(arr) && arr.length > 0; @@ -24,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 @@ -36,6 +40,8 @@ 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', } export type OpenDiagramAction = { @@ -77,6 +83,16 @@ export type RedoEditAction = { type: DiagramActionTypes.REDO_EDIT; }; +export type CollectionSelectedAction = { + type: DiagramActionTypes.COLLECTION_SELECTED; + namespace: string; +}; + +export type RelationSelectedAction = { + type: DiagramActionTypes.RELATIONSHIP_SELECTED; + relationship: Relationship; +}; + export type DiagramActions = | OpenDiagramAction | DeleteDiagramAction @@ -85,7 +101,9 @@ export type DiagramActions = | ApplyEditAction | ApplyEditFailedAction | UndoEditAction - | RedoEditAction; + | RedoEditAction + | CollectionSelectedAction + | RelationSelectedAction; const INITIAL_STATE: DiagramState = null; @@ -104,6 +122,7 @@ export const diagramReducer: Reducer = ( current, next: [], }, + selectedItems: null, }; } @@ -136,6 +155,7 @@ export const diagramReducer: Reducer = ( ], next: [], }, + selectedItems: null, }; } @@ -176,16 +196,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 { @@ -223,9 +253,128 @@ 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.relationship.id, + }, + }; + } + if (isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_CANCELED)) { + if (state.selectedItems?.type === 'relationship') { + return { + ...state, + // Just unselect when canceling edit on relationship, this is basically + // the same as closing the drawer in this case + selectedItems: null, + }; + } + return state; + } + if (isAction(action, SidePanelActionTypes.SIDE_PANEL_CLOSE_CLICKED)) { + return { + ...state, + selectedItems: null, + }; + } + if (isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_SUBMITTED)) { + if ( + state.selectedItems?.type === 'relationship' && + state.selectedItems.id === action.relationshipId + ) { + return { + ...state, + selectedItems: null, + }; + } + return state; + } return state; }; +export function selectCollection( + namespace: string +): DataModelingThunkAction, CollectionSelectedAction> { + return async (dispatch, getState) => { + const { diagram, sidePanel } = getState(); + + if ( + diagram?.selectedItems?.type === 'collection' && + diagram.selectedItems.id === namespace + ) { + return; + } + + if ( + sidePanel.viewType === 'relationship-editing' && + sidePanel.relationshipFormState.modified + ) { + const allowSelect = await showConfirmation({ + title: 'You have unsaved chages', + description: + 'You are currently editing a relationship. Are you sure you want to select a different collection?', + }); + if (!allowSelect) { + return; + } + } + + dispatch({ type: DiagramActionTypes.COLLECTION_SELECTED, namespace }); + }; +} + +export function selectRelationship( + rId: string +): DataModelingThunkAction, RelationSelectedAction> { + return async (dispatch, getState) => { + const { diagram, sidePanel } = getState(); + + if ( + diagram?.selectedItems?.type === 'relationship' && + diagram.selectedItems.id === rId + ) { + return; + } + + if ( + sidePanel.viewType === 'relationship-editing' && + sidePanel.relationshipFormState.modified + ) { + const allowSelect = await showConfirmation({ + title: 'You have unsaved chages', + description: + 'You are currently editing a relationship. Are you sure you want to select a different relationship?', + }); + if (!allowSelect) { + return; + } + } + + const model = selectCurrentModel( + getCurrentDiagramFromState(getState()).edits + ); + + const relationship = model.relationships.find((r) => { + return r.id === rId; + }); + + if (relationship) { + dispatch({ + type: DiagramActionTypes.RELATIONSHIP_SELECTED, + relationship, + }); + } + }; +} + export function undoEdit(): DataModelingThunkAction { return (dispatch, getState, { dataModelStorage }) => { dispatch({ type: DiagramActionTypes.UNDO_EDIT }); @@ -256,28 +405,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; }; } @@ -340,6 +489,71 @@ export function renameDiagram( }; } +export function updateRelationship( + rId: string, + options: { + localCollection: string; + localField: string; + foreignCollection: string; + foreignField: string; + cardinality: string; + } +): DataModelingThunkAction { + return (dispatch, getState) => { + const { + localCollection, + localField, + foreignCollection, + foreignField, + cardinality, + } = options; + const existingRelationship = selectCurrentModel( + getCurrentDiagramFromState(getState()).edits + ).relationships.find((r) => { + return r.id === rId; + }); + const [localCardinality, foreignCardinality] = (() => { + switch (cardinality) { + case 'one-to-one': + return [1, 1]; + case 'one-to-many': + return [1, 2]; + case 'many-to-one': + return [2, 1]; + case 'many-to-many': + return [2, 2]; + default: + throw new Error('Unexpected cardinality'); + } + })(); + return dispatch( + applyEdit({ + type: existingRelationship ? 'UpdateRelationship' : 'AddRelationship', + relationship: { + id: rId, + relationship: [ + { + ns: localCollection, + cardinality: localCardinality, + fields: [localField], + }, + { + ns: foreignCollection, + cardinality: foreignCardinality, + fields: [foreignField], + }, + ], + isInferred: false, + }, + }) + ); + }; +} + +export function deleteRelationship(relationshipId: string) { + return applyEdit({ type: 'RemoveRelationship', relationshipId }); +} + function _applyEdit(edit: Edit, model?: StaticModel): StaticModel { if (edit.type === 'SetModel') { return edit.model; @@ -362,6 +576,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, @@ -382,11 +610,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'); @@ -395,19 +627,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); } @@ -432,4 +663,36 @@ 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); diff --git a/packages/compass-data-modeling/src/store/export-diagram.ts b/packages/compass-data-modeling/src/store/export-diagram.ts index 67117a25276..729addaeda8 100644 --- a/packages/compass-data-modeling/src/store/export-diagram.ts +++ b/packages/compass-data-modeling/src/store/export-diagram.ts @@ -118,7 +118,9 @@ export function exportDiagram( const cancelController = (cancelExportControllerRef.current = new AbortController()); - const model = selectCurrentModel(getCurrentDiagramFromState(getState())); + const model = selectCurrentModel( + getCurrentDiagramFromState(getState()).edits + ); if (exportFormat === 'json') { exportToJson(diagram.name, model); } else if (exportFormat === 'png') { diff --git a/packages/compass-data-modeling/src/store/side-panel.ts b/packages/compass-data-modeling/src/store/side-panel.ts index e4ed063d445..44e398dcee8 100644 --- a/packages/compass-data-modeling/src/store/side-panel.ts +++ b/packages/compass-data-modeling/src/store/side-panel.ts @@ -1,52 +1,260 @@ import type { Reducer } from 'redux'; import { isAction } from './util'; +import { DiagramActionTypes, updateRelationship } from './diagram'; +import type { + Relationship, + RelationshipSide, +} from '../services/data-model-storage'; +import { UUID } from 'bson'; +import type { DataModelingThunkAction } from './reducer'; -export type SidePanelState = { - isOpen: boolean; +type RelationshipEditingState = { + relationshipId: string; + localCollection: string; + localField: string; + foreignCollection: string; + foreignField: string; + cardinality: string; + modified: boolean; }; +/** + * The source of truth for the current side panel state is driven by currently + * selected items on the diagram stored in the `diagram` slice, when + * transitioning to special states inside the drawer for a currently selected + * items, we can add new view types to the side-panel state that would extend + * it. In theory this is a view-only state, on practice we need to make sure + * it's preserved when user navigates around the app (and so components can + * unmount themselves) and so that's why it has its own slice. + */ +export type SidePanelState = + | { + viewType: 'relationship-editing'; + relationshipFormState: RelationshipEditingState; + } + | { viewType: null; relationshipFormState?: never }; + export enum SidePanelActionTypes { - SIDE_PANEL_OPENED = 'data-modeling/side-panel/SIDE_PANEL_OPENED', - SIDE_PANEL_CLOSED = 'data-modeling/side-panel/SIDE_PANEL_CLOSED', + CREATE_NEW_RELATIONSHIP_CLICKED = 'data-modeling/side-panel/CREATE_NEW_RELATIONSHIP_CLICKED', + EDIT_RELATIONSHIP_CLICKED = 'data-modeling/side-panel/EDIT_RELATIONSHIP_CLICKED', + EDIT_RELATIONSHIP_FORM_FIELD_CHANGED = 'data-modeling/side-panel/EDIT_RELATIONSHIP_FORM_FIELD_CHANGED', + EDIT_RELATIONSHIP_FORM_SUBMITTED = 'data-modeling/side-panel/EDIT_RELATIONSHIP_FORM_SUBMITTED', + EDIT_RELATIONSHIP_FORM_CANCELED = 'data-modeling/side-panel/EDIT_RELATIONSHIP_FORM_CANCELED', + SIDE_PANEL_CLOSE_CLICKED = 'data-modeling/side-panel/SIDE_PANEL_CLOSE_CLICKED', +} + +export type CreateNewRelationshipClickedAction = { + type: SidePanelActionTypes.CREATE_NEW_RELATIONSHIP_CLICKED; + namespace: string; +}; + +export function createNewRelationship( + namespace: string +): CreateNewRelationshipClickedAction { + return { + type: SidePanelActionTypes.CREATE_NEW_RELATIONSHIP_CLICKED, + namespace, + }; +} + +export type EditRelationshipClickedAction = { + type: SidePanelActionTypes.EDIT_RELATIONSHIP_CLICKED; + relationship: Relationship; +}; + +export function startRelationshipEdit( + relationship: Relationship +): EditRelationshipClickedAction { + return { + type: SidePanelActionTypes.EDIT_RELATIONSHIP_CLICKED, + relationship, + }; +} + +export type RelationshipFormFields = Exclude< + keyof RelationshipEditingState, + 'relationshipId' | 'modified' +>; + +export type EditRelationshipFormFieldChangedAction = { + type: SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_FIELD_CHANGED; + field: RelationshipFormFields; + value: string; +}; + +export function changeRelationshipFormField( + field: RelationshipFormFields, + value: string +): EditRelationshipFormFieldChangedAction { + return { + type: SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_FIELD_CHANGED, + field, + value, + }; +} + +export type EditRelationshipFormSubmittedAction = { + type: SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_SUBMITTED; + relationshipId: string; +}; + +export function submitRelationshipEdit(): DataModelingThunkAction< + void, + EditRelationshipFormSubmittedAction +> { + return (dispatch, getState) => { + const { viewType, relationshipFormState } = getState().sidePanel; + + if (viewType !== 'relationship-editing') { + return; + } + + const { + relationshipId, + localCollection, + localField, + foreignCollection, + foreignField, + cardinality, + } = relationshipFormState; + + dispatch( + updateRelationship(relationshipId, { + localCollection, + localField, + foreignCollection, + foreignField, + cardinality, + }) + ); + dispatch({ + type: SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_SUBMITTED, + relationshipId, + }); + }; } -export type SidePanelOpenedAction = { - type: SidePanelActionTypes.SIDE_PANEL_OPENED; +export type EditRelationshipFormCanceledAction = { + type: SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_CANCELED; }; -export type SidePanelClosedAction = { - type: SidePanelActionTypes.SIDE_PANEL_CLOSED; +export function cancelRelationshipEditing(): EditRelationshipFormCanceledAction { + return { type: SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_CANCELED }; +} + +export type SidePanelCloseClickedAction = { + type: SidePanelActionTypes.SIDE_PANEL_CLOSE_CLICKED; }; -export type SidePanelActions = SidePanelOpenedAction | SidePanelClosedAction; +export function closeSidePanel(): SidePanelCloseClickedAction { + return { type: SidePanelActionTypes.SIDE_PANEL_CLOSE_CLICKED }; +} + +export type SidePanelActions = + | CreateNewRelationshipClickedAction + | EditRelationshipClickedAction + | EditRelationshipFormFieldChangedAction + | EditRelationshipFormSubmittedAction + | EditRelationshipFormCanceledAction + | SidePanelCloseClickedAction; const INITIAL_STATE: SidePanelState = { - isOpen: false, + viewType: null, }; +function relationshipsCardinalityToFormCardinality( + localRelationship: RelationshipSide, + foreignRelationship: RelationshipSide +) { + return [ + localRelationship.cardinality === 1 ? 'one' : 'many', + foreignRelationship.cardinality === 1 ? 'one' : 'many', + ].join('-to-'); +} + export const sidePanelReducer: Reducer = ( state = INITIAL_STATE, action ) => { - if (isAction(action, SidePanelActionTypes.SIDE_PANEL_OPENED)) { + if (isAction(action, SidePanelActionTypes.CREATE_NEW_RELATIONSHIP_CLICKED)) { return { - ...state, - isOpen: true, + viewType: 'relationship-editing', + relationshipFormState: { + relationshipId: new UUID().toHexString(), + localCollection: action.namespace, + localField: '', + foreignCollection: '', + foreignField: '', + cardinality: '', + modified: false, + }, }; } - if (isAction(action, SidePanelActionTypes.SIDE_PANEL_CLOSED)) { + if ( + isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_CLICKED) || + // We don't have a non-editable state for relationship view, so immediately + // switch to editing mode when relationship is selected + isAction(action, DiagramActionTypes.RELATIONSHIP_SELECTED) + ) { + const { + id, + relationship: [local, foreign], + } = action.relationship; return { + viewType: 'relationship-editing', + relationshipFormState: { + relationshipId: id, + localCollection: local.ns, + localField: local.fields[0] ?? '', // TODO: not sure what's expected here? a multiselect? + foreignCollection: foreign.ns, + foreignField: foreign.fields[0] ?? '', + cardinality: relationshipsCardinalityToFormCardinality(local, foreign), + modified: false, + }, + }; + } + if ( + isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_FIELD_CHANGED) + ) { + if (state.viewType !== 'relationship-editing') { + return state; + } + if (state.relationshipFormState[action.field] === action.value) { + return state; + } + const newState = { ...state, - isOpen: false, + relationshipFormState: { + ...state.relationshipFormState, + [action.field]: action.value, + modified: true, + }, }; + // We only allow to select from the list that is based on collection value, + // when `collection` is changed, reset the `field` field + if (action.field === 'localCollection') { + newState.relationshipFormState.localField = ''; + } + if (action.field === 'foreignCollection') { + newState.relationshipFormState.foreignField = ''; + } + return newState; + } + if (isAction(action, DiagramActionTypes.APPLY_EDIT)) { + if ( + action.edit.type === 'RemoveRelationship' && + action.edit.relationshipId === state.relationshipFormState?.relationshipId + ) { + return { viewType: null }; + } + return state; + } + if ( + isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_CANCELED) || + isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_SUBMITTED) || + isAction(action, DiagramActionTypes.COLLECTION_SELECTED) + ) { + return { viewType: null }; } return state; }; - -export const openSidePanel = (): SidePanelOpenedAction => ({ - type: SidePanelActionTypes.SIDE_PANEL_OPENED, -}); - -export const closeSidePanel = (): SidePanelClosedAction => ({ - type: SidePanelActionTypes.SIDE_PANEL_CLOSED, -}); From eb5d54dc035d3d1967732aea37cb95426a84a160 Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Thu, 17 Jul 2025 18:10:43 +0200 Subject: [PATCH 2/3] chore(data-modeling): add tests --- .../components/collection-drawer-content.tsx | 2 +- .../diagram-editor-side-panel.spec.tsx | 123 ++++ .../src/components/diagram-editor.tsx | 5 + .../relationship-drawer-content.tsx | 6 +- .../src/store/diagram.ts | 41 +- .../src/store/side-panel.ts | 3 +- .../data-model-with-relationships.json | 540 ++++++++++++++++++ 7 files changed, 714 insertions(+), 6 deletions(-) create mode 100644 packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx create mode 100644 packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json diff --git a/packages/compass-data-modeling/src/components/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/collection-drawer-content.tsx index 7538578052c..0b78f48e318 100644 --- a/packages/compass-data-modeling/src/components/collection-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/collection-drawer-content.tsx @@ -43,7 +43,7 @@ const CollectionDrawerContent: React.FunctionComponent<
    {relationships.map((r) => { return ( -
  • +
  • {r.relationship[0].fields.join(', ')} ->  {r.relationship[1].fields.join(', ')}
- + ); }; diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 13afb0cd4cc..31f7daa9847 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -42,6 +42,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', + DIAGRAM_BACKGROUND_SELECTED = 'data-modeling/diagram/DIAGRAM_BACKGROUND_SELECTED', } export type OpenDiagramAction = { @@ -93,6 +94,10 @@ export type RelationSelectedAction = { relationship: Relationship; }; +export type DiagramBackgroundSelectedAction = { + type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED; +}; + export type DiagramActions = | OpenDiagramAction | DeleteDiagramAction @@ -103,7 +108,8 @@ export type DiagramActions = | UndoEditAction | RedoEditAction | CollectionSelectedAction - | RelationSelectedAction; + | RelationSelectedAction + | DiagramBackgroundSelectedAction; const INITIAL_STATE: DiagramState = null; @@ -297,6 +303,12 @@ export const diagramReducer: Reducer = ( } return state; } + if (isAction(action, DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED)) { + return { + ...state, + selectedItems: null, + }; + } return state; }; @@ -375,6 +387,33 @@ export function selectRelationship( }; } +export function selectBackground(): DataModelingThunkAction< + Promise, + DiagramBackgroundSelectedAction +> { + return async (dispatch, getState) => { + const { sidePanel } = getState(); + + if ( + sidePanel.viewType === 'relationship-editing' && + sidePanel.relationshipFormState.modified + ) { + const allowSelect = await showConfirmation({ + title: 'You have unsaved chages', + description: + 'You are currently editing a relationship. Are you sure you want to stop editing?', + }); + if (!allowSelect) { + return; + } + } + + dispatch({ + type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED, + }); + }; +} + export function undoEdit(): DataModelingThunkAction { return (dispatch, getState, { dataModelStorage }) => { dispatch({ type: DiagramActionTypes.UNDO_EDIT }); diff --git a/packages/compass-data-modeling/src/store/side-panel.ts b/packages/compass-data-modeling/src/store/side-panel.ts index 44e398dcee8..7a9189581b7 100644 --- a/packages/compass-data-modeling/src/store/side-panel.ts +++ b/packages/compass-data-modeling/src/store/side-panel.ts @@ -252,7 +252,8 @@ export const sidePanelReducer: Reducer = ( if ( isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_CANCELED) || isAction(action, SidePanelActionTypes.EDIT_RELATIONSHIP_FORM_SUBMITTED) || - isAction(action, DiagramActionTypes.COLLECTION_SELECTED) + isAction(action, DiagramActionTypes.COLLECTION_SELECTED) || + isAction(action, DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED) ) { return { viewType: null }; } 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..aaae9aca819 --- /dev/null +++ b/packages/compass-data-modeling/test/fixtures/data-model-with-relationships.json @@ -0,0 +1,540 @@ +{ + "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 + } + }, + { + "id": "270bc8a9-566e-4387-9d84-d0b777822947", + "timestamp": "2025-07-11T11:01:26.318Z", + "type": "MoveCollection", + "ns": "flights.countries", + "newPosition": [219.40735570787513, 979.18365450302] + }, + { + "id": "437cf39d-5db7-4508-96bf-cfa581e7586f", + "timestamp": "2025-07-14T13:17:30.228Z", + "type": "MoveCollection", + "ns": "flights.planes", + "newPosition": [609.3925977542651, 567.3885505528234] + }, + { + "id": "eee357c2-8e7f-4bdc-97bf-92452fc929b6", + "timestamp": "2025-07-14T13:17:32.356Z", + "type": "MoveCollection", + "ns": "flights.airports", + "newPosition": [-90.61474783348534, 489.6768071064075] + }, + { + "id": "6dd87643-34cc-4316-a0c7-239afb14cf29", + "timestamp": "2025-07-14T13:53:04.812Z", + "type": "MoveCollection", + "ns": "flights.airports", + "newPosition": [-198.6464279915867, 552.4048794562729] + }, + { + "id": "aed7de69-d6da-4cd6-9cfa-ad168c3bbbd2", + "timestamp": "2025-07-14T13:53:06.155Z", + "type": "MoveCollection", + "ns": "flights.planes", + "newPosition": [743.5609747248104, 692.8446952525541] + }, + { + "id": "edb81252-00c6-4246-b386-5dc57ab5d77e", + "timestamp": "2025-07-14T13:53:07.541Z", + "type": "MoveCollection", + "ns": "flights.countries", + "newPosition": [409.334019211634, 564.4813984122438] + }, + { + "id": "5c216650-4f94-4bc0-a86f-d6998f955ba7", + "timestamp": "2025-07-14T13:53:11.383Z", + "type": "MoveCollection", + "ns": "flights.airports_coordinates_for_schema", + "newPosition": [552.1160786088143, 229.65583041119885] + }, + { + "id": "2fc01a1c-9a77-4d04-88a2-75ae1e723d4b", + "timestamp": "2025-07-14T13:53:12.608Z", + "type": "MoveCollection", + "ns": "flights.routes", + "newPosition": [851.6053350549475, 360.1288043921029] + }, + { + "id": "5a0a17c5-5a1d-403d-9376-4592ac178553", + "timestamp": "2025-07-14T13:53:14.813Z", + "type": "MoveCollection", + "ns": "flights.planes", + "newPosition": [121.50759058864625, 973.378574372785] + }, + { + "id": "d32b091a-5f88-49df-a3ee-df2879b6c6ac", + "timestamp": "2025-07-14T13:53:17.755Z", + "type": "MoveCollection", + "ns": "flights.countries", + "newPosition": [658.5038621569322, 824.1059200825196] + }, + { + "id": "0a36d07f-d787-4901-9415-446b33d598db", + "timestamp": "2025-07-14T13:53:18.858Z", + "type": "MoveCollection", + "ns": "flights.planes", + "newPosition": [121.50759058864625, 856.634661943869] + }, + { + "id": "9d34b954-7636-4ae7-b26d-3f6b30727bdf", + "timestamp": "2025-07-14T13:53:55.706Z", + "type": "MoveCollection", + "ns": "flights.airports_coordinates_for_schema", + "newPosition": [387.78686853696104, 186.3442556635786] + }, + { + "id": "66e5b3fc-9464-479d-956e-d55ca7ba36ce", + "timestamp": "2025-07-14T13:53:57.156Z", + "type": "MoveCollection", + "ns": "flights.airports_coordinates_for_schema", + "newPosition": [743.1965554365506, 102.26884585937458] + }, + { + "id": "58e90723-a7d5-4e12-8b2a-52168e191344", + "timestamp": "2025-07-14T13:54:09.420Z", + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [166.97481820374284, 161.81444130145675] + }, + { + "id": "83287bca-cf13-4085-9c23-9cb72cd6f877", + "timestamp": "2025-07-14T13:54:10.721Z", + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [56.14814164365578, 159.26670161042026] + }, + { + "id": "c7b64382-0b61-47cb-9981-0adad1606093", + "timestamp": "2025-07-14T14:01:59.729Z", + "type": "MoveCollection", + "ns": "flights.routes", + "newPosition": [905.6167270451696, 486.15538570262083] + }, + { + "id": "362774a3-d083-4cd0-b8e7-4f72b677d586", + "timestamp": "2025-07-14T14:02:01.057Z", + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [432.59117672702115, 268.9261944390528] + }, + { + "id": "27193354-41ba-48aa-8b19-dd3aab6e0359", + "timestamp": "2025-07-14T14:30:18.279Z", + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [343.3983833938203, -610.880334566423] + }, + { + "id": "4959b8d0-78a5-426f-8365-39c0a4f2b345", + "timestamp": "2025-07-15T12:18:22.918Z", + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [242.92856982576654, 142.6432671939803] + }, + { + "id": "12f3c89c-fbe3-44c0-9039-bad4be6be371", + "timestamp": "2025-07-15T12:18:29.799Z", + "type": "MoveCollection", + "ns": "flights.airports_coordinates_for_schema", + "newPosition": [470.11241988358745, 436.5357658804881] + }, + { + "id": "7d8796a9-b6a4-45a4-b997-65ddfe946a70", + "timestamp": "2025-07-15T12:18:35.463Z", + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [198.16067875151032, 326.19162059843103] + }, + { + "id": "4bdaf312-3324-4f11-a2f6-b2bfb1a63345", + "timestamp": "2025-07-15T12:18:38.206Z", + "type": "MoveCollection", + "ns": "flights.airports_coordinates_for_schema", + "newPosition": [467.12789381197035, 430.5667137372539] + }, + { + "id": "bbdcda75-ed3a-45ea-9998-b1eb5d7cdaef", + "timestamp": "2025-07-15T12:18:42.880Z", + "type": "MoveCollection", + "ns": "flights.routes", + "newPosition": [374.37108629732853, 557.7840114214308] + }, + { + "id": "2366cf7b-ed7b-4a05-b37a-28bab3733dba", + "timestamp": "2025-07-15T12:18:51.023Z", + "type": "MoveCollection", + "ns": "flights.airports_coordinates_for_schema", + "newPosition": [467.12789381197035, 433.551239808871] + }, + { + "id": "d1c1b69d-33d0-45f0-8c47-84c6ea56a1ed", + "timestamp": "2025-07-15T12:18:58.823Z", + "type": "MoveCollection", + "ns": "flights.routes", + "newPosition": [765.3440016791667, 453.3255989148329] + }, + { + "id": "19342f14-64ea-4b0d-bf62-3e438f70f403", + "timestamp": "2025-07-15T12:19:03.839Z", + "type": "MoveCollection", + "ns": "flights.countries", + "newPosition": [455.5560892869704, 616.6813581051322] + }, + { + "id": "b280b29d-ccd3-4128-805c-2dd0a9e7f8b1", + "timestamp": "2025-07-15T12:19:08.929Z", + "type": "MoveCollection", + "ns": "flights.airports", + "newPosition": [144.57407024437805, 595.6805074947205] + }, + { + "id": "0d776d36-ef62-4afd-8a1e-281359b27718", + "timestamp": "2025-07-15T12:19:11.206Z", + "type": "MoveCollection", + "ns": "flights.planes", + "newPosition": [766.1652220579366, 291.0669713724315] + }, + { + "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" + }, + { + "type": "RemoveRelationship", + "relationshipId": "6f776467-4c98-476b-9b71-1f8a724e6c2c", + "id": "cf0aeeb2-78b3-40ab-abcc-886630504c2b", + "timestamp": "2025-07-17T10:09:02.529Z" + }, + { + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [213.44223754771434, 317.4593012863144], + "id": "c38befd6-61df-42f3-bdb9-429a6ebd00bf", + "timestamp": "2025-07-17T11:30:51.454Z" + }, + { + "type": "MoveCollection", + "ns": "flights.airlines", + "newPosition": [177.42142038523338, 275.98078455376054], + "id": "f53e8069-af2d-4b3d-aed1-ba3af4853ca5", + "timestamp": "2025-07-17T11:30:52.271Z" + }, + { + "type": "MoveCollection", + "ns": "flights.airports", + "newPosition": [159.85562904058213, 530.1881126538459], + "id": "dedfe068-cd9f-4d1c-8fd1-5cebb400693e", + "timestamp": "2025-07-17T11:30:53.585Z" + }, + { + "type": "MoveCollection", + "ns": "flights.airports", + "newPosition": [167.49640843868417, 536.7373521379334], + "id": "dec23099-2a01-4d59-8786-1a558970b68d", + "timestamp": "2025-07-17T11:30:54.452Z" + }, + { + "type": "MoveCollection", + "ns": "flights.airports", + "newPosition": [167.49640843868417, 536.7373521379334], + "id": "6e29e453-4ea8-435b-919f-8245a1abddd6", + "timestamp": "2025-07-17T12:07:37.740Z" + } + ], + "createdAt": "2025-06-20T06:35:26.773Z", + "updatedAt": "2025-07-17T12:07:37.740Z" +} From b3b435b6261f3abcd105f28b4e7215889d022f37 Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Fri, 18 Jul 2025 09:33:44 +0200 Subject: [PATCH 3/3] fix(data-modeling): un-only the test and fix types --- .../src/components/diagram-editor-side-panel.spec.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index 0f78732954b..c46f0b4c25a 100644 --- 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 @@ -17,6 +17,7 @@ import { 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, @@ -36,7 +37,7 @@ async function comboboxSelectItem( }); } -describe.only('DiagramEditorSidePanel', function () { +describe('DiagramEditorSidePanel', function () { function renderDrawer() { const { renderWithConnections } = createPluginTestHelpers( DataModelingWorkspaceTab.provider.withMockServices({}) @@ -44,7 +45,9 @@ describe.only('DiagramEditorSidePanel', function () { const result = renderWithConnections( ); - result.plugin.store.dispatch(openDiagram(dataModel)); + result.plugin.store.dispatch( + openDiagram(dataModel as MongoDBDataModelDescription) + ); return result; }