diff --git a/packages/compass-components/src/components/drawer-portal.tsx b/packages/compass-components/src/components/drawer-portal.tsx index b0dfe27b3e8..71ae368d1ed 100644 --- a/packages/compass-components/src/components/drawer-portal.tsx +++ b/packages/compass-components/src/components/drawer-portal.tsx @@ -296,10 +296,10 @@ export { DrawerDisplayMode }; export function useDrawerActions() { const actions = useContext(DrawerActionsContext); const stableActions = useRef({ - openDrawer(id: string) { + openDrawer: (id: string) => { actions.current.openDrawer(id); }, - closeDrawer() { + closeDrawer: () => { actions.current.closeDrawer(); }, }); diff --git a/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx b/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx index 4cc93d4c6ce..e1355a230cd 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.spec.tsx @@ -7,7 +7,7 @@ import { render, userEvent, } from '@mongodb-js/testing-library-compass'; -import DiagramEditor, { getFieldsFromSchema } from './diagram-editor'; +import DiagramEditor from './diagram-editor'; import type { DataModelingStore } from '../../test/setup-store'; import type { Edit, @@ -18,6 +18,7 @@ import sinon from 'sinon'; import { DiagramProvider } from '@mongodb-js/diagramming'; import { DataModelingWorkspaceTab } from '..'; import { openDiagram } from '../store/diagram'; +import { getFieldsFromSchema } from '../utils/nodes-and-edges'; const storageItems: MongoDBDataModelDescription[] = [ { @@ -68,14 +69,14 @@ const storageItems: MongoDBDataModelDescription[] = [ { ns: 'db1.collection1', indexes: [], - displayPosition: [NaN, NaN], + displayPosition: [0, 0], shardKey: {}, jsonSchema: { bsonType: 'object' }, }, { ns: 'db1.collection2', indexes: [], - displayPosition: [NaN, NaN], + displayPosition: [0, 0], shardKey: {}, jsonSchema: { bsonType: 'object' }, }, @@ -165,37 +166,6 @@ describe('DiagramEditor', function () { .callsFake(mockDiagramming.applyLayout as any); }); - context('with initial diagram', function () { - beforeEach(async function () { - const result = renderDiagramEditor({ - renderedItem: storageItems[1], - }); - store = result.store; - - // wait till the editor is loaded - await waitFor(() => { - expect(screen.getByTestId('model-preview')).to.be.visible; - }); - }); - - it('applies the initial layout to unpositioned nodes', function () { - const state = store.getState(); - - expect(state.diagram?.edits.current).to.have.lengthOf(1); - expect(state.diagram?.edits.current[0].type).to.equal('SetModel'); - const initialEdit = state.diagram?.edits.current[0] as Extract< - Edit, - { type: 'SetModel' } - >; - expect(initialEdit.model?.collections[0].displayPosition).to.deep.equal([ - 100, 100, - ]); - expect(initialEdit.model?.collections[1].displayPosition).to.deep.equal([ - 200, 200, - ]); - }); - }); - context('with existing diagram', function () { beforeEach(async function () { const result = renderDiagramEditor({ diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index c81dc0b92dd..39e8339b97d 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -1,16 +1,7 @@ -import React, { - useCallback, - useMemo, - useRef, - useEffect, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { connect } from 'react-redux'; -import toNS from 'mongodb-ns'; -import type { MongoDBJSONSchema } from 'mongodb-schema'; import type { DataModelingState } from '../store/reducer'; import { - applyInitialLayout, moveCollection, getCurrentDiagramFromState, selectCurrentModel, @@ -21,15 +12,14 @@ import { } from '../store/diagram'; import { Banner, - Body, CancelLoader, WorkspaceContainer, css, spacing, Button, useDarkMode, - InlineDefinition, useDrawerActions, + rafraf, } from '@mongodb-js/compass-components'; import { cancelAnalysis, retryAnalysis } from '../store/analysis-process'; import { @@ -37,13 +27,16 @@ import { type NodeProps, type EdgeProps, useDiagram, - applyLayout, } from '@mongodb-js/diagramming'; -import type { Relationship, StaticModel } from '../services/data-model-storage'; +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 { DATA_MODELING_DRAWER_ID } from './diagram-editor-side-panel'; +import { + collectionToDiagramNode, + getSelectedFields, + relationshipToDiagramEdge, +} from '../utils/nodes-and-edges'; const loadingContainerStyles = css({ width: '100%', @@ -66,12 +59,6 @@ const bannerButtonStyles = css({ marginLeft: 'auto', }); -const mixedTypeTooltipContentStyles = css({ - overflowWrap: 'anywhere', - textWrap: 'wrap', - textAlign: 'left', -}); - const ErrorBannerWithRetry: React.FunctionComponent<{ onRetryClick: () => void; }> = ({ children, onRetryClick }) => { @@ -89,121 +76,6 @@ const ErrorBannerWithRetry: React.FunctionComponent<{ ); }; -function getBsonTypeName(bsonType: string) { - switch (bsonType) { - case 'array': - return '[]'; - default: - return bsonType; - } -} - -function getFieldTypeDisplay(bsonTypes: string[]) { - if (bsonTypes.length === 0) { - return 'unknown'; - } - - if (bsonTypes.length === 1) { - return getBsonTypeName(bsonTypes[0]); - } - - const typesString = bsonTypes - .map((bsonType) => getBsonTypeName(bsonType)) - .join(', '); - - // We show `mixed` with a tooltip when multiple bsonTypes were found. - return ( - - Multiple types found in sample: {typesString} - - } - > -
(mixed)
-
- ); -} - -export const getFieldsFromSchema = ( - jsonSchema: MongoDBJSONSchema, - highlightedFields: string[] = [], - depth = 0 -): NodeProps['fields'] => { - if (!jsonSchema || !jsonSchema.properties) { - return []; - } - let fields: NodeProps['fields'] = []; - for (const [name, field] of Object.entries(jsonSchema.properties)) { - // field has types, properties and (optional) children - // types are either direct, or from anyof - // children are either direct (properties), from anyOf, items or items.anyOf - const types: (string | string[])[] = []; - const children: (MongoDBJSONSchema | MongoDBJSONSchema[])[] = []; - if (field.bsonType) types.push(field.bsonType); - if (field.properties) children.push(field); - if (field.items) - children.push((field.items as MongoDBJSONSchema).anyOf || field.items); - if (field.anyOf) { - for (const variant of field.anyOf) { - if (variant.bsonType) types.push(variant.bsonType); - if (variant.properties) { - children.push(variant); - } - if (variant.items) children.push(variant.items); - } - } - - fields.push({ - name, - type: getFieldTypeDisplay(types.flat()), - depth: depth, - glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [], - variant: - highlightedFields.length && - highlightedFields[highlightedFields.length - 1] === name - ? 'preview' - : undefined, - }); - - if (children.length > 0) { - fields = [ - ...fields, - ...children - .flat() - .flatMap((child) => - getFieldsFromSchema( - child, - name === highlightedFields[0] ? highlightedFields.slice(1) : [], - depth + 1 - ) - ), - ]; - } - } - - return fields; -}; - -const getSelectedFields = ( - selectedItems: SelectedItems | null, - relationships?: Relationship[] -) => { - if (!selectedItems || selectedItems.type !== 'relationship') return {}; - const { id } = selectedItems; - const relationship = relationships?.find((rel) => rel.id === id); - if ( - !relationship || - !relationship.relationship[0].ns || - !relationship.relationship[1].ns - ) - return {}; - return { - [relationship.relationship[0].ns]: relationship.relationship[0].fields, - [relationship.relationship[1].ns]: relationship.relationship[1].fields, - }; -}; - const modelPreviewContainerStyles = css({ display: 'grid', gridTemplateColumns: '100%', @@ -218,14 +90,10 @@ const modelPreviewStyles = css({ type SelectedItems = NonNullable['selectedItems']; -const DiagramEditor: React.FunctionComponent<{ +const DiagramContent: React.FunctionComponent<{ diagramLabel: string; - step: DataModelingState['step']; model: StaticModel | null; editErrors?: string[]; - onRetryClick: () => void; - onCancelClick: () => void; - onApplyInitialLayout: (positions: Record) => void; onMoveCollection: (ns: string, newPosition: [number, number]) => void; onCollectionSelect: (namespace: string) => void; onRelationshipSelect: (rId: string) => void; @@ -233,49 +101,31 @@ const DiagramEditor: React.FunctionComponent<{ selectedItems: SelectedItems; }> = ({ diagramLabel, - step, model, - onRetryClick, - onCancelClick, - onApplyInitialLayout, onMoveCollection, onCollectionSelect, onRelationshipSelect, onDiagramBackgroundClicked, selectedItems, }) => { - const { log, mongoLogId } = useLogger('COMPASS-DATA-MODELING-DIAGRAM-EDITOR'); const isDarkMode = useDarkMode(); - const diagramContainerRef = useRef(null); - const diagram = useDiagram(); - const [areNodesReady, setAreNodesReady] = useState(false); + const diagram = useRef(useDiagram()); const { openDrawer } = useDrawerActions(); - const setDiagramContainerRef = useCallback( - (ref: HTMLDivElement | null) => { - if (ref) { - // For debugging purposes, we attach the diagram to the ref. - (ref as any)._diagram = diagram; - } - diagramContainerRef.current = ref; - }, - [diagram] - ); - - const edges = useMemo(() => { - return (model?.relationships ?? []).map((relationship): EdgeProps => { - const [source, target] = relationship.relationship; - return { - id: relationship.id, - 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, - }; + const setDiagramContainerRef = useCallback((ref: HTMLDivElement | null) => { + if (ref) { + // For debugging purposes, we attach the diagram to the ref. + (ref as any)._diagram = diagram.current; + } + }, []); + + const edges = useMemo(() => { + return (model?.relationships ?? []).map((relationship) => { + const selected = + !!selectedItems && + selectedItems.type === 'relationship' && + selectedItems.id === relationship.id; + return relationshipToDiagramEdge(relationship, selected); }); }, [model?.relationships, selectedItems]); @@ -284,70 +134,95 @@ const DiagramEditor: React.FunctionComponent<{ selectedItems, model?.relationships ); - return (model?.collections ?? []).map( - (coll): NodeProps => ({ - id: coll.ns, - type: 'collection', - position: { - x: coll.displayPosition[0], - y: coll.displayPosition[1], - }, - title: toNS(coll.ns).collection, - fields: getFieldsFromSchema( - coll.jsonSchema, - selectedFields[coll.ns] || undefined, - 0 - ), - selected: - !!selectedItems && - selectedItems.type === 'collection' && - selectedItems.id === coll.ns, - }) - ); + return (model?.collections ?? []).map((coll) => { + const selected = + !!selectedItems && + selectedItems.type === 'collection' && + selectedItems.id === coll.ns; + return collectionToDiagramNode(coll, selectedFields, selected); + }); }, [model?.collections, model?.relationships, selectedItems]); - const applyInitialLayout = useCallback(async () => { - try { - const { nodes: positionedNodes } = await applyLayout( - nodes, - edges, - 'LEFT_RIGHT' - ); - onApplyInitialLayout( - Object.fromEntries( - positionedNodes.map((node) => [ - node.id, - [node.position.x, node.position.y], - ]) - ) - ); - } catch (err) { - log.error( - mongoLogId(1_001_000_361), - 'DiagramEditor', - 'Error applying layout:', - err - ); - } - }, [edges, log, nodes, mongoLogId, onApplyInitialLayout]); - + // Fit to view on initial mount useEffect(() => { - if (nodes.length === 0) return; - const isInitialState = nodes.some( - (node) => isNaN(node.position.x) || isNaN(node.position.y) - ); - if (isInitialState) { - void applyInitialLayout(); - return; - } - if (!areNodesReady) { - setAreNodesReady(true); - setTimeout(() => { - void diagram.fitView(); - }); - } - }, [areNodesReady, nodes, diagram, applyInitialLayout]); + // Schedule the fitView call to make sure that diagramming package had a + // chance to set initial nodes, edges state + // TODO: react-flow documentation suggests that we should be able to do this + // without unrelyable scheduling by calling the fitView after initial state + // is set, but for this we will need to make some changes to the diagramming + // package first + return rafraf(() => { + void diagram.current.fitView(); + }); + }, []); + + return ( +
+
+ { + if (node.type !== 'collection') { + return; + } + onCollectionSelect(node.id); + openDrawer(DATA_MODELING_DRAWER_ID); + }} + onPaneClick={onDiagramBackgroundClicked} + onEdgeClick={(_evt, edge) => { + onRelationshipSelect(edge.id); + openDrawer(DATA_MODELING_DRAWER_ID); + }} + fitViewOptions={{ + maxZoom: 1, + minZoom: 0.25, + }} + onNodeDragStop={(evt, node) => { + onMoveCollection(node.id, [node.position.x, node.position.y]); + }} + /> +
+
+ ); +}; +const ConnectedDiagramContent = connect( + (state: DataModelingState) => { + const { diagram } = state; + return { + model: diagram + ? selectCurrentModel(getCurrentDiagramFromState(state).edits) + : null, + diagramLabel: diagram?.name || 'Schema Preview', + selectedItems: state.diagram?.selectedItems ?? null, + }; + }, + { + onMoveCollection: moveCollection, + onCollectionSelect: selectCollection, + onRelationshipSelect: selectRelationship, + onDiagramBackgroundClicked: selectBackground, + } +)(DiagramContent); + +const DiagramEditor: React.FunctionComponent<{ + step: DataModelingState['step']; + diagramId?: string; + onRetryClick: () => void; + onCancelClick: () => void; +}> = ({ step, diagramId, onRetryClick, onCancelClick }) => { let content; if (step === 'NO_DIAGRAM_SELECTED') { @@ -383,46 +258,9 @@ const DiagramEditor: React.FunctionComponent<{ ); } - if (step === 'EDITING') { + if (step === 'EDITING' && diagramId) { content = ( -
-
- { - if (node.type !== 'collection') { - return; - } - onCollectionSelect(node.id); - openDrawer(DATA_MODELING_DRAWER_ID); - }} - onPaneClick={onDiagramBackgroundClicked} - onEdgeClick={(_evt, edge) => { - onRelationshipSelect(edge.id); - openDrawer(DATA_MODELING_DRAWER_ID); - }} - fitViewOptions={{ - maxZoom: 1, - minZoom: 0.25, - }} - onNodeDragStop={(evt, node) => { - onMoveCollection(node.id, [node.position.x, node.position.y]); - }} - /> -
-
+ ); } @@ -439,21 +277,12 @@ export default connect( const { diagram, step } = state; return { step: step, - model: diagram - ? selectCurrentModel(getCurrentDiagramFromState(state).edits) - : null, editErrors: diagram?.editErrors, - diagramLabel: diagram?.name || 'Schema Preview', - selectedItems: state.diagram?.selectedItems ?? null, + diagramId: diagram?.id, }; }, { onRetryClick: retryAnalysis, onCancelClick: cancelAnalysis, - onApplyInitialLayout: applyInitialLayout, - onMoveCollection: moveCollection, - onCollectionSelect: selectCollection, - onRelationshipSelect: selectRelationship, - onDiagramBackgroundClicked: selectBackground, } )(DiagramEditor); 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 333581dbea0..8d95e8a1e40 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -18,21 +18,21 @@ export const RelationshipSchema = z.object({ export type Relationship = z.output; +const CollectionSchema = z.object({ + ns: z.string(), + jsonSchema: z.custom((value) => { + const isObject = typeof value === 'object' && value !== null; + return isObject && 'bsonType' in value; + }), + indexes: z.array(z.record(z.unknown())), + shardKey: z.record(z.unknown()).optional(), + displayPosition: z.tuple([z.number(), z.number()]), +}); + +export type DataModelCollection = z.output; + export const StaticModelSchema = z.object({ - collections: z.array( - z.object({ - ns: z.string(), - jsonSchema: z.custom((value) => { - const isObject = typeof value === 'object' && value !== null; - return isObject && 'bsonType' in value; - }), - indexes: z.array(z.record(z.unknown())), - shardKey: z.record(z.unknown()).optional(), - displayPosition: z - .tuple([z.number(), z.number()]) - .or(z.tuple([z.nan(), z.nan()])), - }) - ), + collections: z.array(CollectionSchema), relationships: z.array(RelationshipSchema), }); @@ -68,6 +68,7 @@ const EditSchemaVariants = z.discriminatedUnion('type', [ ]); export const EditSchema = z.intersection(EditSchemaBase, EditSchemaVariants); + export const EditListSchema = z .array(EditSchema) .nonempty() diff --git a/packages/compass-data-modeling/src/store/analysis-process.ts b/packages/compass-data-modeling/src/store/analysis-process.ts index a13740354f6..48cbec31c19 100644 --- a/packages/compass-data-modeling/src/store/analysis-process.ts +++ b/packages/compass-data-modeling/src/store/analysis-process.ts @@ -6,6 +6,8 @@ import { getCurrentDiagramFromState } from './diagram'; import type { Document } from 'bson'; import type { AggregationCursor } from 'mongodb'; import type { Relationship } from '../services/data-model-storage'; +import { applyLayout } from '@mongodb-js/diagramming'; +import { collectionToDiagramNode } from '../utils/nodes-and-edges'; export type AnalysisProcessState = { currentAnalysisOptions: @@ -62,7 +64,11 @@ export type AnalysisFinishedAction = { type: AnalysisProcessActionTypes.ANALYSIS_FINISHED; name: string; connectionId: string; - collections: { ns: string; schema: MongoDBJSONSchema }[]; + collections: { + ns: string; + schema: MongoDBJSONSchema; + position: { x: number; y: number }; + }[]; relations: Relationship[]; }; @@ -191,17 +197,38 @@ export function startAnalysis( return { ns, schema }; }) ); + if (options.automaticallyInferRelations) { // TODO } + if (cancelController.signal.aborted) { throw cancelController.signal.reason; } + + const positioned = await applyLayout( + collections.map((coll) => { + return collectionToDiagramNode({ + ns: coll.ns, + jsonSchema: coll.schema, + displayPosition: [0, 0], + }); + }), + [], + 'LEFT_RIGHT' + ); + dispatch({ type: AnalysisProcessActionTypes.ANALYSIS_FINISHED, name, connectionId, - collections, + collections: collections.map((coll) => { + const node = positioned.nodes.find((node) => { + return node.id === coll.ns; + }); + const position = node ? node.position : { x: 0, y: 0 }; + return { ...coll, position }; + }), relations: [], }); diff --git a/packages/compass-data-modeling/src/store/diagram.spec.ts b/packages/compass-data-modeling/src/store/diagram.spec.ts index 08ff588a9c3..e51aaf4f64d 100644 --- a/packages/compass-data-modeling/src/store/diagram.spec.ts +++ b/packages/compass-data-modeling/src/store/diagram.spec.ts @@ -2,7 +2,6 @@ import { expect } from 'chai'; import { type DataModelingStore, setupStore } from '../../test/setup-store'; import { applyEdit, - applyInitialLayout, getCurrentDiagramFromState, getCurrentModel, openDiagram, @@ -76,8 +75,16 @@ describe('Data Modeling store', function () { name: 'New Diagram', connectionId: 'connection-id', collections: [ - { ns: 'collection1', schema: model.collections[0].jsonSchema }, - { ns: 'collection2', schema: model.collections[1].jsonSchema }, + { + ns: 'collection1', + schema: model.collections[0].jsonSchema, + position: { x: 0, y: 0 }, + }, + { + ns: 'collection2', + schema: model.collections[1].jsonSchema, + position: { x: 0, y: 0 }, + }, ], relations: model.relationships, }; @@ -98,46 +105,16 @@ describe('Data Modeling store', function () { expect(initialEdit.model.collections[0]).to.deep.include({ ns: newDiagram.collections[0].ns, jsonSchema: newDiagram.collections[0].schema, - displayPosition: [NaN, NaN], + displayPosition: [0, 0], }); expect(initialEdit.model.collections[1]).to.deep.include({ ns: newDiagram.collections[1].ns, jsonSchema: newDiagram.collections[1].schema, - displayPosition: [NaN, NaN], + displayPosition: [0, 0], }); expect(initialEdit.model.relationships).to.deep.equal( newDiagram.relations ); - - // INITIAL LAYOUT - const positions: Record = { - [newDiagram.collections[0].ns]: [10, 10], - [newDiagram.collections[1].ns]: [50, 50], - }; - store.dispatch(applyInitialLayout(positions)); - - const diagramWithLayout = getCurrentDiagramFromState(store.getState()); - expect(diagramWithLayout.name).to.equal(newDiagram.name); - expect(diagramWithLayout.connectionId).to.equal(newDiagram.connectionId); - expect(diagramWithLayout.edits).to.have.length(1); - expect(diagramWithLayout.edits[0].type).to.equal('SetModel'); - const initialEditWithPositions = diagramWithLayout.edits[0] as Extract< - Edit, - { type: 'SetModel' } - >; - expect(initialEditWithPositions.model.collections[0]).to.deep.include({ - ns: newDiagram.collections[0].ns, - jsonSchema: newDiagram.collections[0].schema, - displayPosition: positions[newDiagram.collections[0].ns], - }); - expect(initialEditWithPositions.model.collections[1]).to.deep.include({ - ns: newDiagram.collections[1].ns, - jsonSchema: newDiagram.collections[1].schema, - displayPosition: positions[newDiagram.collections[1].ns], - }); - expect(initialEditWithPositions.model.relationships).to.deep.equal( - newDiagram.relations - ); }); }); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index efd1bdeb160..8d4adf13a56 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -26,6 +26,8 @@ function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { return Array.isArray(arr) && arr.length > 0; } +export type SelectedItems = { type: 'collection' | 'relationship'; id: string }; + export type DiagramState = | (Omit & { edits: { @@ -34,7 +36,7 @@ export type DiagramState = next: Edit[][]; }; editErrors?: string[]; - selectedItems: { type: 'collection' | 'relationship'; id: string } | null; + selectedItems: SelectedItems | null; }) | null; // null when no diagram is currently open @@ -69,11 +71,6 @@ export type RenameDiagramAction = { name: string; }; -export type ApplyInitialLayoutAction = { - type: DiagramActionTypes.APPLY_INITIAL_LAYOUT; - positions: Record; -}; - export type ApplyEditAction = { type: DiagramActionTypes.APPLY_EDIT; edit: Edit; @@ -114,7 +111,6 @@ export type DiagramActions = | OpenDiagramAction | DeleteDiagramAction | RenameDiagramAction - | ApplyInitialLayoutAction | ApplyEditAction | ApplyEditFailedAction | UndoEditAction @@ -163,8 +159,7 @@ export const diagramReducer: Reducer = ( collections: action.collections.map((collection) => ({ ns: collection.ns, jsonSchema: collection.schema, - displayPosition: [NaN, NaN], - // TODO + displayPosition: [collection.position.x, collection.position.y], indexes: [], shardKey: undefined, })), @@ -190,30 +185,6 @@ export const diagramReducer: Reducer = ( updatedAt: new Date().toISOString(), }; } - if (isAction(action, DiagramActionTypes.APPLY_INITIAL_LAYOUT)) { - const initialEdit = state.edits.current[0]; - if (!initialEdit || initialEdit.type !== 'SetModel') { - throw new Error('No initial model edit found to apply layout to'); - } - return { - ...state, - edits: { - ...state.edits, - current: [ - { - ...initialEdit, - model: { - ...initialEdit.model, - collections: initialEdit.model.collections.map((collection) => ({ - ...collection, - displayPosition: action.positions[collection.ns] || [NaN, NaN], - })), - }, - }, - ], - }, - }; - } if (isAction(action, DiagramActionTypes.APPLY_EDIT)) { const newState = { ...state, @@ -407,18 +378,6 @@ export function applyEdit( }; } -export function applyInitialLayout( - positions: Record -): DataModelingThunkAction { - return (dispatch, getState, { dataModelStorage }) => { - dispatch({ - type: DiagramActionTypes.APPLY_INITIAL_LAYOUT, - positions, - }); - void dataModelStorage.save(getCurrentDiagramFromState(getState())); - }; -} - export function openDiagram( diagram: MongoDBDataModelDescription ): OpenDiagramAction { diff --git a/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx new file mode 100644 index 00000000000..9f59061f1c7 --- /dev/null +++ b/packages/compass-data-modeling/src/utils/nodes-and-edges.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import toNS from 'mongodb-ns'; +import { InlineDefinition, Body, css } from '@mongodb-js/compass-components'; +import type { NodeProps, EdgeProps } from '@mongodb-js/diagramming'; +import type { MongoDBJSONSchema } from 'mongodb-schema'; +import type { SelectedItems } from '../store/diagram'; +import type { + DataModelCollection, + Relationship, +} from '../services/data-model-storage'; + +function getBsonTypeName(bsonType: string) { + switch (bsonType) { + case 'array': + return '[]'; + default: + return bsonType; + } +} + +const mixedTypeTooltipContentStyles = css({ + overflowWrap: 'anywhere', + textWrap: 'wrap', + textAlign: 'left', +}); + +function getFieldTypeDisplay(bsonTypes: string[]) { + if (bsonTypes.length === 0) { + return 'unknown'; + } + + if (bsonTypes.length === 1) { + return getBsonTypeName(bsonTypes[0]); + } + + const typesString = bsonTypes + .map((bsonType) => getBsonTypeName(bsonType)) + .join(', '); + + // We show `mixed` with a tooltip when multiple bsonTypes were found. + return ( + + Multiple types found in sample: {typesString} + + } + > +
(mixed)
+
+ ); +} + +export const getSelectedFields = ( + selectedItems: SelectedItems | null, + relationships?: Relationship[] +): Record => { + if (!selectedItems || selectedItems.type !== 'relationship') return {}; + const { id } = selectedItems; + const { relationship } = relationships?.find((rel) => rel.id === id) ?? {}; + const selection: Record = {}; + if (relationship?.[0].ns) { + selection[relationship[0].ns] = relationship[0].fields; + } + if (relationship?.[1].ns) { + selection[relationship[1].ns] = relationship[1].fields; + } + return selection; +}; + +export const getFieldsFromSchema = ( + jsonSchema: MongoDBJSONSchema, + highlightedFields: string[] = [], + depth = 0 +): NodeProps['fields'] => { + if (!jsonSchema || !jsonSchema.properties) { + return []; + } + let fields: NodeProps['fields'] = []; + for (const [name, field] of Object.entries(jsonSchema.properties)) { + // field has types, properties and (optional) children + // types are either direct, or from anyof + // children are either direct (properties), from anyOf, items or items.anyOf + const types: (string | string[])[] = []; + const children: (MongoDBJSONSchema | MongoDBJSONSchema[])[] = []; + if (field.bsonType) { + types.push(field.bsonType); + } + if (field.properties) { + children.push(field); + } + if (field.items) { + children.push((field.items as MongoDBJSONSchema).anyOf || field.items); + } + if (field.anyOf) { + for (const variant of field.anyOf) { + if (variant.bsonType) { + types.push(variant.bsonType); + } + if (variant.properties) { + children.push(variant); + } + if (variant.items) { + children.push(variant.items); + } + } + } + + fields.push({ + name, + type: getFieldTypeDisplay(types.flat()), + depth: depth, + glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [], + variant: + highlightedFields.length && + highlightedFields[highlightedFields.length - 1] === name + ? 'preview' + : undefined, + }); + + if (children.length > 0) { + fields = [ + ...fields, + ...children + .flat() + .flatMap((child) => + getFieldsFromSchema( + child, + name === highlightedFields[0] ? highlightedFields.slice(1) : [], + depth + 1 + ) + ), + ]; + } + } + + return fields; +}; + +export function collectionToDiagramNode( + coll: Pick, + selectedFields: Record = {}, + selected = false +): NodeProps { + return { + id: coll.ns, + type: 'collection', + position: { + x: coll.displayPosition[0], + y: coll.displayPosition[1], + }, + title: toNS(coll.ns).collection, + fields: getFieldsFromSchema( + coll.jsonSchema, + selectedFields[coll.ns] ?? undefined, + 0 + ), + selected, + }; +} + +export function relationshipToDiagramEdge( + relationship: Relationship, + selected = false +): EdgeProps { + const [source, target] = relationship.relationship; + return { + id: relationship.id, + source: source.ns ?? '', + target: target.ns ?? '', + markerStart: source.cardinality === 1 ? 'one' : 'many', + markerEnd: target.cardinality === 1 ? 'one' : 'many', + selected, + }; +}