- 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..baf3c8e8d91 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;
+ selectedItem?: string | null;
}> = ({
diagramLabel,
step,
@@ -198,7 +203,10 @@ const DiagramEditor: React.FunctionComponent<{
onCancelClick,
onApplyInitialLayout,
onMoveCollection,
- onOpenSidePanel,
+ onCollectionSelect,
+ onRelationshipSelect,
+ onDiagramBackgroundClicked,
+ selectedItem,
}) => {
const { log, mongoLogId } = useLogger('COMPASS-DATA-MODELING-DIAGRAM-EDITOR');
const isDarkMode = useDarkMode();
@@ -226,9 +234,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 +250,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 +345,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 +387,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 +401,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..84eb8563429
--- /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 da488b2389d..6c8320beba4 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';
import { downloadDiagram } from '../services/open-and-download-diagram';
function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] {
@@ -25,6 +28,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 +41,9 @@ 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',
}
export type OpenDiagramAction = {
@@ -78,6 +85,20 @@ 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 DiagramBackgroundSelectedAction = {
+ type: DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED;
+};
+
export type DiagramActions =
| OpenDiagramAction
| DeleteDiagramAction
@@ -86,7 +107,10 @@ export type DiagramActions =
| ApplyEditAction
| ApplyEditFailedAction
| UndoEditAction
- | RedoEditAction;
+ | RedoEditAction
+ | CollectionSelectedAction
+ | RelationSelectedAction
+ | DiagramBackgroundSelectedAction;
const INITIAL_STATE: DiagramState = null;
@@ -105,6 +129,7 @@ export const diagramReducer: Reducer = (
current,
next: [],
},
+ selectedItems: null,
};
}
@@ -137,6 +162,7 @@ export const diagramReducer: Reducer = (
],
next: [],
},
+ selectedItems: null,
};
}
@@ -177,16 +203,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 +260,161 @@ 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;
+ }
+ if (isAction(action, DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED)) {
+ return {
+ ...state,
+ selectedItems: null,
+ };
+ }
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 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 });
@@ -257,28 +445,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 +539,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;
@@ -373,6 +626,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 +660,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 +677,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 +713,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 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/side-panel.ts b/packages/compass-data-modeling/src/store/side-panel.ts
index e4ed063d445..7a9189581b7 100644
--- a/packages/compass-data-modeling/src/store/side-panel.ts
+++ b/packages/compass-data-modeling/src/store/side-panel.ts
@@ -1,52 +1,261 @@
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) ||
+ isAction(action, DiagramActionTypes.DIAGRAM_BACKGROUND_SELECTED)
+ ) {
+ return { viewType: null };
}
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..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"
+}