+ {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"
+}