@eventcatalogtest/studio
Version:
A drag and drop UI for distributed systems that keeps your diagrams where they belong – in your repo
551 lines (496 loc) • 21.2 kB
text/typescript
import { Edge, ReactFlowInstance, Node, applyNodeChanges, NodeChange, addEdge, Connection, MarkerType, ReactFlowJsonObject } from '@xyflow/react';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import ELK from 'elkjs/lib/elk.bundled.js';
import { getEdgeOptionsForConnection, isValidConnection } from '@/utils/node-rule-engine';
import { useNotificationsStore } from './notifications-store';
import { getNodeId } from '@/utils/react-flow';
import slugify from 'slugify';
import { nanoid } from 'nanoid';
interface HistoryState {
nodes: Node[];
edges: Edge[];
}
interface Store {
reactFlowInstance: ReactFlowInstance | null;
nodes: Node[];
edges: Edge[];
history: HistoryState[];
historyIndex: number;
actions: {
addNode: (node: Node) => void;
addEdge: (edge: Edge) => void;
deleteNode: (id: string) => void;
deleteEdge: (id: string) => void;
setReactFlowInstance: (instance: ReactFlowInstance) => void;
onNodesChange: (changes: NodeChange<any>[]) => void;
setEdges: (change: any) => void;
onConnect: (connection: Connection) => void;
duplicateNode: (id: string) => void;
updateNode: (node: Node) => void;
updateEdge: (edge: Edge) => void;
reset: () => void;
exportFlow: () => any;
importFlow: (data: ReactFlowJsonObject) => void;
saveCurrentFlowToDesign: () => void;
autoLayout: () => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
saveToHistory: () => void;
checkAndAssignParentIds: (nodes: Node[]) => Node[];
sortNodesByParentChild: (nodes: Node[]) => Node[];
};
}
const initialNodes: Node[] = [];
// const initialEdges = [{ id: 'e1-2', source: '1', target: '2' }];
const initialEdges: Edge[] = [];
// TODO: Get the initial edges and nodes from localstorage?
const useFlowStore = create<Store>()(
persist(
(set, get) => ({
reactFlowInstance: null,
nodes: initialNodes,
edges: initialEdges,
history: [{ nodes: initialNodes, edges: initialEdges }],
historyIndex: 0,
actions: {
addNode: (node: Node) => {
get().actions.saveToHistory();
const newNodes = [...get().nodes, node];
const sortedNodes = get().actions.sortNodesByParentChild(newNodes);
set({ nodes: sortedNodes });
},
addEdge: (edge: Edge) => {
get().actions.saveToHistory();
set((state) => ({ edges: [...state.edges, edge] }));
},
setEdges: (change: any) => {
let newChange = typeof change === "function" ? change(get().edges) : change;
set({
edges: newChange,
});
// get().updateCurrentFlow({ edges: newChange });
},
duplicateNode: (id: string) => {
const node = get().nodes.find((node) => node.id === id);
const nodes = get().nodes;
// Set all previous nodes to selected false
nodes.forEach((node) => {
node.selected = false;
});
// Get the window mouse position
if (node) {
get().actions.addNode({
...node,
id: getNodeId(node.type ?? ''),
position: {
x: node.position.x + 100,
y: node.position.y + 150,
},
selected: false,
});
}
},
deleteNode: (id: string) => {
get().actions.saveToHistory();
set((state) => ({ nodes: state.nodes.filter((node) => node.id !== id) }));
},
deleteEdge: (id: string) => {
get().actions.saveToHistory();
set((state) => ({ edges: state.edges.filter((edge) => edge.id !== id) }));
},
setReactFlowInstance: (instance: ReactFlowInstance) => set({ reactFlowInstance: instance }),
onNodesChange: (changes: NodeChange<any>[]) => {
// Check if there are position changes that ended (drag end)
const hasPositionEnd = changes.some(change =>
change.type === 'position' && 'dragging' in change && !change.dragging
);
// Check for dimensions changes (resize end)
const hasDimensionsEnd = changes.some(change =>
change.type === 'dimensions' && 'resizing' in change && !change.resizing
);
if (hasPositionEnd || hasDimensionsEnd) {
get().actions.saveToHistory();
}
// Apply the changes first
const updatedNodes = applyNodeChanges(changes, get().nodes);
// Check for nodes that need parentId assignment after drag end
if (hasPositionEnd) {
const finalNodes = get().actions.checkAndAssignParentIds(updatedNodes);
// Sort nodes so parent nodes come before their children
const sortedNodes = get().actions.sortNodesByParentChild(finalNodes);
set({ nodes: sortedNodes });
} else {
set({ nodes: updatedNodes });
}
},
reset: () => {
set({
nodes: [],
edges: [],
});
},
onConnect: (connection: Connection) => {
let newEdges: Edge[] = [];
const sourceNode = get().nodes.find((node) => node.id === connection.source);
const targetNode = get().nodes.find((node) => node.id === connection.target);
const validConnection = isValidConnection(sourceNode?.type ?? '', targetNode?.type ?? '', connection);
if (!validConnection) {
useNotificationsStore.getState().addNotification({
message: `Invalid connection between ${sourceNode?.type} and ${targetNode?.type}`,
type: 'error',
duration: 5000,
});
return;
}
const edgeOptions = getEdgeOptionsForConnection(sourceNode?.type ?? '', targetNode?.type ?? '', connection);
get().actions.setEdges((oldEdges: Edge[]) => {
newEdges = addEdge(
{
...connection,
...edgeOptions,
animated: true,
type: 'animatedMessage',
data: {
source: sourceNode?.type,
target: targetNode?.type,
message: {
collection: 'events',
opacity: 1,
},
},
},
oldEdges,
);
return newEdges;
});
},
updateNode: (node: Node) => {
const nodes = get().nodes;
set((state) => ({ nodes: state.nodes.map((n) => n.id === node.id ? node : n) }))
},
updateEdge: (edge: Edge) => {
get().actions.saveToHistory();
set((state) => ({ edges: state.edges.map((e) => e.id === edge.id ? edge : e) }));
},
exportFlow: () => {
const flowData = get().reactFlowInstance?.toObject();
if (!flowData) return null;
// Get current design name from design store
let designName = 'Untitled Design';
let designId = '';
try {
// Dynamically import to avoid circular dependency
const { useDesignStore } = require('./design-store');
const currentDesign = useDesignStore.getState().currentDesign;
if (currentDesign?.name) {
designName = currentDesign.name;
}
if (currentDesign?.id) {
designId = currentDesign.id;
}
} catch (error) {
// Fallback if design store is not available
console.warn('Could not access design store for name');
}
// Get current document
let document = null;
try {
const { useDocumentStore } = require('./document-store');
document = useDocumentStore.getState().getCurrentDocument();
} catch (error) {
console.warn('Could not access document store');
}
// Add metadata fields to the root of the object
const dataWithMetadata = {
...flowData,
creationDate: new Date().toISOString(),
name: designName,
version: '1.0',
source: 'https://app.eventcatalog.dev',
document,
appState: {},
id: `${slugify(designName, { lower: true })}`
};
return dataWithMetadata;
},
importFlow: (data: ReactFlowJsonObject & { creationDate?: string; name?: string; version?: string; source?: string; appState?: any; document?: any }) => {
get().reactFlowInstance?.setViewport(data.viewport);
// set the nodes and edges
set({
nodes: data.nodes,
edges: data.edges,
});
// Handle document import - load document into current document store
try {
const { useDocumentStore } = require('./document-store');
if (data.document) {
// Use setTimeout to ensure document import happens after the flow import is complete
setTimeout(() => {
// Check if it's the new object format
if (data.document.id && data.document.content) {
useDocumentStore.getState().setCurrentDocument(data.document);
}
// Legacy support: if it's still an array format, convert it
else if (Array.isArray(data.document) && data.document.length > 0) {
const convertedDoc = {
id: `doc-${Date.now()}`,
name: 'Imported Document',
content: data.document,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
useDocumentStore.getState().setCurrentDocument(convertedDoc);
}
}, 100);
}
} catch (error) {
console.warn('Could not import document:', error);
}
// Handle metadata if present
if (data.creationDate || data.name || data.version) {
console.log('Loaded file metadata:', {
creationDate: data.creationDate,
name: data.name,
version: data.version,
source: data.source,
appState: data.appState,
hasDocument: !!(data.document?.length)
});
}
},
saveCurrentFlowToDesign: () => {
const flowData = get().actions.exportFlow();
// @ts-ignore
if (flowData) {
// Dynamically import to avoid circular dependency
import('./design-store').then(({ useDesignStore }) => {
useDesignStore.getState().updateCurrentDesignData(flowData);
});
}
},
autoLayout: async () => {
const { nodes, edges } = get();
if (nodes.length === 0) {
useNotificationsStore.getState().addNotification({
message: 'No nodes to layout',
type: 'warning',
duration: 3000,
});
return;
}
// Save current state before auto-layout
get().actions.saveToHistory();
const elk = new ELK();
// Convert ReactFlow nodes and edges to ELK format
const elkNodes = nodes.map(node => ({
id: node.id,
width: node.measured?.width || 200,
height: node.measured?.height || 100,
}));
const elkEdges = edges.map(edge => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
}));
const elkGraph = {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'RIGHT',
'elk.spacing.nodeNode': '80',
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
'elk.layered.spacing.edgeNodeBetweenLayers': '30',
},
children: elkNodes,
edges: elkEdges,
};
try {
const layoutedGraph = await elk.layout(elkGraph);
// Update node positions based on ELK layout
const layoutedNodes = nodes.map(node => {
const elkNode = layoutedGraph.children?.find(n => n.id === node.id);
if (elkNode && elkNode.x !== undefined && elkNode.y !== undefined) {
return {
...node,
position: { x: elkNode.x, y: elkNode.y },
};
}
return node;
});
set({ nodes: layoutedNodes });
useNotificationsStore.getState().addNotification({
message: 'Nodes arranged automatically',
type: 'info',
duration: 3000,
});
} catch (error) {
useNotificationsStore.getState().addNotification({
message: 'Failed to auto-layout nodes',
type: 'error',
duration: 5000,
});
}
},
saveToHistory: () => {
const { nodes, edges, history, historyIndex } = get();
// Remove any future history if we're not at the end
const newHistory = history.slice(0, historyIndex + 1);
// Add current state to history
newHistory.push({ nodes: [...nodes], edges: [...edges] });
// Limit history to 50 entries
const limitedHistory = newHistory.slice(-50);
set({
history: limitedHistory,
historyIndex: limitedHistory.length - 1,
});
},
undo: () => {
const { history, historyIndex } = get();
if (historyIndex > 0) {
const previousState = history[historyIndex - 1];
if (previousState) {
set({
nodes: [...previousState.nodes],
edges: [...previousState.edges],
historyIndex: historyIndex - 1,
});
}
}
},
redo: () => {
const { history, historyIndex } = get();
if (historyIndex < history.length - 1) {
const nextState = history[historyIndex + 1];
if (nextState) {
set({
nodes: [...nextState.nodes],
edges: [...nextState.edges],
historyIndex: historyIndex + 1,
});
}
}
},
canUndo: () => {
const { historyIndex } = get();
return historyIndex > 0;
},
canRedo: () => {
const { history, historyIndex } = get();
return historyIndex < history.length - 1;
},
checkAndAssignParentIds: (nodes: Node[]) => {
// Helper function to check if a point is inside a rectangle
const isPointInside = (point: { x: number; y: number }, rect: { x: number; y: number; width: number; height: number }) => {
return point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height;
};
// Find all domain nodes (potential parents)
const domainNodes = nodes.filter(node => node.type === 'domain');
// Process all non-domain nodes to check if they should be assigned to a domain
return nodes.map(node => {
// Skip domain nodes themselves
if (node.type === 'domain') {
return node;
}
// Calculate the node's absolute position (accounting for parentId)
let nodeAbsolutePosition = { ...node.position };
if (node.parentId) {
const currentParent = nodes.find(n => n.id === node.parentId);
if (currentParent) {
nodeAbsolutePosition = {
x: node.position.x + currentParent.position.x,
y: node.position.y + currentParent.position.y
};
}
}
// Find all domains that contain this node
const containingDomains = domainNodes.filter(domain => {
// Calculate domain bounds (accounting for node dimensions)
const widthValue = domain.measured?.width || domain.style?.width || 400;
const heightValue = domain.measured?.height || domain.style?.height || 300;
const domainBounds = {
x: domain.position.x,
y: domain.position.y,
width: typeof widthValue === 'number' ? widthValue : parseInt(String(widthValue)) || 400,
height: typeof heightValue === 'number' ? heightValue : parseInt(String(heightValue)) || 300
};
// Check if the node's center point is inside the domain
const nodeCenter = {
x: nodeAbsolutePosition.x + ((node.measured?.width || 200) / 2),
y: nodeAbsolutePosition.y + ((node.measured?.height || 100) / 2)
};
console.log(`Checking node ${node.id} (center: ${nodeCenter.x}, ${nodeCenter.y}) against domain ${domain.id} (bounds: ${domainBounds.x}, ${domainBounds.y}, ${domainBounds.width}, ${domainBounds.height})`);
return isPointInside(nodeCenter, domainBounds);
});
// If multiple domains contain the node, pick the smallest one (most specific)
const parentDomain = containingDomains.length > 0
? containingDomains.reduce((smallest, current) => {
const smallestWidthValue = smallest.measured?.width || smallest.style?.width || 400;
const smallestHeightValue = smallest.measured?.height || smallest.style?.height || 300;
const currentWidthValue = current.measured?.width || current.style?.width || 400;
const currentHeightValue = current.measured?.height || current.style?.height || 300;
const smallestWidth = typeof smallestWidthValue === 'number' ? smallestWidthValue : parseInt(String(smallestWidthValue)) || 400;
const smallestHeight = typeof smallestHeightValue === 'number' ? smallestHeightValue : parseInt(String(smallestHeightValue)) || 300;
const currentWidth = typeof currentWidthValue === 'number' ? currentWidthValue : parseInt(String(currentWidthValue)) || 400;
const currentHeight = typeof currentHeightValue === 'number' ? currentHeightValue : parseInt(String(currentHeightValue)) || 300;
const smallestArea = smallestWidth * smallestHeight;
const currentArea = currentWidth * currentHeight;
return currentArea < smallestArea ? current : smallest;
})
: null;
// Update parentId based on whether node is inside a domain
if (parentDomain && node.parentId !== parentDomain.id) {
// Convert absolute position to relative position within the parent
const relativePosition = {
x: node.position.x - parentDomain.position.x,
y: node.position.y - parentDomain.position.y
};
return {
...node,
parentId: parentDomain.id,
position: relativePosition,
extent: 'parent' as const
};
} else if (!parentDomain && node.parentId) {
// Node was moved out of its parent domain
const parentNode = nodes.find(n => n.id === node.parentId);
if (parentNode) {
// Convert relative position back to absolute position
const absolutePosition = {
x: node.position.x + parentNode.position.x,
y: node.position.y + parentNode.position.y
};
return {
...node,
parentId: undefined,
position: absolutePosition,
extent: undefined
};
}
}
return node;
});
},
sortNodesByParentChild: (nodes: Node[]) => {
// Separate parent nodes (domains) and child nodes
const parentNodes = nodes.filter(node => node.type === 'domain');
const childNodes = nodes.filter(node => node.type !== 'domain');
// Return parent nodes first, then child nodes
return [...parentNodes, ...childNodes];
},
},
}),
{
name: 'flow-storage', // unique name for localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ nodes: state.nodes, edges: state.edges }), // only persist nodes and edges
}
)
);
export default useFlowStore;
export const useFlowStoreActions = () => useFlowStore((state) => state.actions);