diff --git a/.gitignore b/.gitignore index 4a2f08ac..dda4ae8a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ node_modules # storybook *storybook.log storybook-static + +.DS_Store diff --git a/diagram-editor/dist.tar.gz b/diagram-editor/dist.tar.gz index 3b66c756..3cb26b43 100644 Binary files a/diagram-editor/dist.tar.gz and b/diagram-editor/dist.tar.gz differ diff --git a/diagram-editor/frontend/add-operation.tsx b/diagram-editor/frontend/add-operation.tsx index da6f8912..809f3f58 100644 --- a/diagram-editor/frontend/add-operation.tsx +++ b/diagram-editor/frontend/add-operation.tsx @@ -1,119 +1,83 @@ -import { Button, ButtonGroup, styled } from '@mui/material'; +import { Button, ButtonGroup, Stack, TextField, Typography, styled } from '@mui/material'; import type { NodeAddChange, XYPosition } from '@xyflow/react'; import React from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { EditorMode, useEditorMode } from './editor-mode'; import { useNodeManager } from './node-manager'; import type { DiagramEditorNode } from './nodes'; import { BufferAccessIcon, BufferIcon, - createOperationNode, - createScopeNode, - createSectionBufferNode, - createSectionInputNode, - createSectionOutputNode, ForkCloneIcon, ForkResultIcon, isOperationNode, - isSectionBufferNode, - isSectionInputNode, - isSectionOutputNode, JoinIcon, ListenIcon, NodeIcon, ScopeIcon, SectionBufferIcon, - type SectionBufferNode, SectionIcon, SectionInputIcon, - type SectionInputNode, SectionOutputIcon, - type SectionOutputNode, SplitIcon, StreamOutIcon, TransformIcon, UnzipIcon, } from './nodes'; -import type { DiagramOperation, NextOperation } from './types/api'; +import { + type AddOperationKey, + filterCompatibleAddOperations, + getVisibleAddOperations, +} from './utils/add-operation-catalog'; import { joinNamespaces, ROOT_NAMESPACE } from './utils/namespace'; -import { addUniqueSuffix } from './utils/unique-value'; const StyledOperationButton = styled(Button)({ justifyContent: 'flex-start', }); -export interface AddOperationProps { - parentId?: string; - newNodePosition: XYPosition; - onAdd?: (change: NodeAddChange[]) => void; -} - -function createSectionInputChange( - remappedId: string, - targetId: NextOperation, - position: XYPosition, -): NodeAddChange { - return { - type: 'add', - item: createSectionInputNode(remappedId, targetId, position), - }; -} - -function createSectionOutputChange( - outputId: string, - position: XYPosition, -): NodeAddChange { - return { - type: 'add', - item: createSectionOutputNode(outputId, position), - }; -} +const OPERATION_ICONS: Record = { + sectionInput: , + sectionOutput: , + sectionBuffer: , + node: , + fork_clone: , + unzip: , + fork_result: , + split: , + join: , + transform: , + buffer: , + buffer_access: , + listen: , + stream_out: , + scope: , + section: , +}; -function createSectionBufferChange( - remappedId: string, - targetId: NextOperation, - position: XYPosition, -): NodeAddChange { - return { - type: 'add', - item: createSectionBufferNode(remappedId, targetId, position), - }; +export interface AddOperationSelection { + primaryNodeId: string; + changes: NodeAddChange[]; } -function createNodeChange( - namespace: string, - parentId: string | undefined, - newNodePosition: XYPosition, - op: DiagramOperation, -): NodeAddChange[] { - if (op.type === 'scope') { - return createScopeNode( - namespace, - parentId, - newNodePosition, - op, - uuidv4(), - ).map((node) => ({ type: 'add', item: node })); - } - - return [ - { - type: 'add', - item: createOperationNode( - namespace, - parentId, - newNodePosition, - op, - uuidv4(), - ), - }, - ]; +export interface AddOperationProps { + parentId?: string; + newNodePosition: XYPosition; + sourceConnection?: { + sourceNodeId: string; + sourceHandle: string | null; + sourceHandleType: 'source' | 'target'; + } | null; + onAdd?: (selection: AddOperationSelection) => void; } -function AddOperation({ parentId, newNodePosition, onAdd }: AddOperationProps) { +function AddOperation({ + parentId, + newNodePosition, + sourceConnection, + onAdd, +}: AddOperationProps) { const [editorMode] = useEditorMode(); const nodeManager = useNodeManager(); + const [search, setSearch] = React.useState(''); const namespace = React.useMemo(() => { const parentNode = parentId && nodeManager.tryGetNode(parentId); if (!parentNode || !isOperationNode(parentNode)) { @@ -121,254 +85,118 @@ function AddOperation({ parentId, newNodePosition, onAdd }: AddOperationProps) { } return joinNamespaces(parentNode.data.namespace, parentNode.data.opId); }, [parentId, nodeManager]); + const sourceNode = sourceConnection + ? nodeManager.tryGetNode(sourceConnection.sourceNodeId) + : null; + const compatibleOperations = React.useMemo(() => { + let visible = getVisibleAddOperations({ + isTemplateMode: editorMode.mode === EditorMode.Template, + namespace, + }); + + if (sourceNode) { + visible = filterCompatibleAddOperations( + visible, + sourceNode, + sourceConnection?.sourceHandle, + { namespace, parentId }, + sourceConnection?.sourceHandleType, + ); + } + + return visible; + }, [ + editorMode.mode, + namespace, + sourceNode, + sourceConnection?.sourceHandle, + sourceConnection?.sourceHandleType, + parentId, + ]); + + const operations = React.useMemo(() => { + const trimmedSearch = search.trim().toLowerCase(); + if (!trimmedSearch) { + return compatibleOperations; + } + + return compatibleOperations.filter((operation) => + operation.label.toLowerCase().includes(trimmedSearch), + ); + }, [compatibleOperations, search]); + + const emptyMessage = React.useMemo(() => { + if (operations.length > 0) { + return null; + } + + if (search.trim()) { + return sourceConnection + ? sourceConnection.sourceHandleType === 'target' + ? 'No compatible input operations match this filter.' + : 'No compatible output operations match this filter.' + : 'No operations match this filter.'; + } + + return sourceConnection + ? sourceConnection.sourceHandleType === 'target' + ? 'No compatible operations are available for this input yet.' + : 'No compatible operations are available for this output yet.' + : 'No operations are available here yet.'; + }, [operations.length, search, sourceConnection]); + + const title = sourceConnection + ? sourceConnection.sourceHandleType === 'target' + ? 'Compatible previous operations' + : 'Compatible next operations' + : 'Add operation'; return ( - - {editorMode.mode === EditorMode.Template && - namespace === ROOT_NAMESPACE && ( - } - onClick={() => { - const remappedId = addUniqueSuffix( - 'new_input', - nodeManager.nodes - .filter(isSectionInputNode) - .map((n) => n.data.remappedId), - ); - onAdd?.([ - createSectionInputChange( - remappedId, - { builtin: 'dispose' }, - newNodePosition, - ), - ]); - }} - > - Section Input - - )} - {editorMode.mode === EditorMode.Template && - namespace === ROOT_NAMESPACE && ( - } - onClick={() => { - const outputId = addUniqueSuffix( - 'new_output', - nodeManager.nodes - .filter(isSectionOutputNode) - .map((n) => n.data.outputId), - ); - onAdd?.([createSectionOutputChange(outputId, newNodePosition)]); - }} - > - Section Output - - )} - {editorMode.mode === EditorMode.Template && - namespace === ROOT_NAMESPACE && ( - } - onClick={() => { - const remappedId = addUniqueSuffix( - 'new_buffer', - nodeManager.nodes - .filter(isSectionBufferNode) - .map((n) => n.data.remappedId), - ); - onAdd?.([ - createSectionBufferChange( - remappedId, - { builtin: 'dispose' }, + + {title} + setSearch(event.target.value)} + /> + {operations.length > 0 && ( + + {operations.map((operation) => ( + { + const changes = operation.createChanges({ + namespace, + parentId, newNodePosition, - ), - ]); - }} - > - Section Buffer - - )} - } - onClick={() => { - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'node', - builder: '', - next: { builtin: 'dispose' }, - }), - ); - }} - > - Node - - {/* */} - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'fork_clone', - next: [], - }), - ) - } - > - Fork Clone - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'unzip', - next: [], - }), - ) - } - > - Unzip - - } - onClick={() => { - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'fork_result', - err: { builtin: 'dispose' }, - ok: { builtin: 'dispose' }, - }), - ); - }} - > - Fork Result - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'split', - }), - ) - } - > - Split - - } - onClick={() => { - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'join', - buffers: [], - next: { builtin: 'dispose' }, - }), - ); - }} - > - Join - - } - onClick={() => { - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'transform', - cel: '', - next: { builtin: 'dispose' }, - }), - ); - }} - > - Transform - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'buffer', - }), - ) - } - > - Buffer - - } - onClick={() => { - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'buffer_access', - buffers: [], - next: { builtin: 'dispose' }, - }), - ); - }} - > - Buffer Access - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'listen', - buffers: [], - next: { builtin: 'dispose' }, - }), - ) - } - > - Listen - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'stream_out', - name: '', - }), - ) - } - > - Stream Out - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'scope', - start: { builtin: 'dispose' }, - ops: {}, - next: { builtin: 'dispose' }, - }), - ) - } - > - Scope - - } - onClick={() => - onAdd?.( - createNodeChange(namespace, parentId, newNodePosition, { - type: 'section', - template: '', - }), - ) - } - > - Section - - + nodeManager, + }); + const primaryNodeId = changes[0]?.item.id; + if (!primaryNodeId) { + return; + } + onAdd?.({ changes, primaryNodeId }); + }} + > + {operation.label} + + ))} + + )} + {emptyMessage && ( + + {emptyMessage} + + )} + ); } diff --git a/diagram-editor/frontend/api-client/base-api-client.ts b/diagram-editor/frontend/api-client/base-api-client.ts index 64df17a8..6dbbd4e1 100644 --- a/diagram-editor/frontend/api-client/base-api-client.ts +++ b/diagram-editor/frontend/api-client/base-api-client.ts @@ -2,7 +2,7 @@ import type { Observable } from 'rxjs'; import type { Diagram, DiagramElementMetadata } from '../types/api'; export interface BaseApiClient { - getRegistry(): Observable; + getRegistry(): Observable; postRunWorkflow(diagram: Diagram, request: unknown): Observable; // WIP // wsDebugWorkflow(diagram: Diagram, request: unknown): Promise; diff --git a/diagram-editor/frontend/api.preprocessed.schema.json b/diagram-editor/frontend/api.preprocessed.schema.json index 16fdfd70..9245002a 100644 --- a/diagram-editor/frontend/api.preprocessed.schema.json +++ b/diagram-editor/frontend/api.preprocessed.schema.json @@ -58,20 +58,6 @@ ], "type": "object" }, - "BufferIdentifier": { - "anyOf": [ - { - "description": "Identify a buffer by name", - "type": "string" - }, - { - "description": "Identify a buffer by an index value", - "format": "uint", - "minimum": 0, - "type": "integer" - } - ] - }, "BufferMapLayoutHints": { "oneOf": [ { @@ -741,6 +727,20 @@ ], "type": "object" }, + "IdentifierRef": { + "anyOf": [ + { + "description": "Identify by a name", + "type": "string" + }, + { + "description": "Identify by an index value", + "format": "uint", + "minimum": 0, + "type": "integer" + } + ] + }, "InputExample": { "properties": { "description": { @@ -781,7 +781,7 @@ "clone": { "description": "List of the keys in the `buffers` dictionary whose value should be cloned\ninstead of removed from the buffer (pulled) when the join occurs. Cloning\nthe value will leave the buffer unchanged after the join operation takes\nplace.", "items": { - "$ref": "#/$defs/BufferIdentifier" + "$ref": "#/$defs/IdentifierRef" }, "type": "array" }, diff --git a/diagram-editor/frontend/app.css b/diagram-editor/frontend/app.css index 9234ce78..3bc7ba3c 100644 --- a/diagram-editor/frontend/app.css +++ b/diagram-editor/frontend/app.css @@ -13,6 +13,11 @@ width: 8px; height: 8px; z-index: 10; + transition: + box-shadow 120ms ease, + opacity 120ms ease, + background-color 120ms ease, + border-color 120ms ease; } .react-flow__handle::before { content: ""; @@ -42,3 +47,25 @@ var(--xy-handle-background-color-default) 50% ); } +.react-flow__handle.connectionindicator { + opacity: 1; + border: 1px solid transparent; + box-shadow: none; +} +.react-flow__handle.handle-compatible { + border: 1px solid var(--mui-palette-success-main); + box-shadow: 0 0 0 4px rgb(76 175 80 / 22%); +} +.react-flow__handle.handle-compatible:hover, +.react-flow__handle.valid { + box-shadow: 0 0 0 6px rgb(76 175 80 / 30%); + border-color: var(--mui-palette-success-main); +} +.react-flow__handle.connectingto:not(.valid) { + opacity: 0.9; + box-shadow: 0 0 0 6px rgb(244 67 54 / 24%); + border-color: var(--mui-palette-error-main); +} +.react-flow__handle.connectingfrom { + box-shadow: 0 0 0 6px rgb(33 150 243 / 18%); +} diff --git a/diagram-editor/frontend/connection-hint-panel.tsx b/diagram-editor/frontend/connection-hint-panel.tsx new file mode 100644 index 00000000..73b08b36 --- /dev/null +++ b/diagram-editor/frontend/connection-hint-panel.tsx @@ -0,0 +1,115 @@ +import { Paper, Stack, Typography } from '@mui/material'; +import { Panel, useConnection } from '@xyflow/react'; +import type { NodeManager } from './node-manager'; +import { + filterCompatibleAddOperations, + getVisibleAddOperations, +} from './utils/add-operation-catalog'; +import { + createConnectionFromDraggedHandle, + validateConnectionSimple, + validateSourceOutputCapacity, +} from './utils/connection'; +import { ROOT_NAMESPACE } from './utils/namespace'; +import { useEdges } from './use-edges'; + +export interface ConnectionHintPanelProps { + nodeManager: NodeManager; +} + +export function ConnectionHintPanel({ nodeManager }: ConnectionHintPanelProps) { + const connection = useConnection(); + const edges = useEdges(); + + if (!connection.inProgress || !connection.fromHandle) { + return null; + } + + const sourceNode = nodeManager.tryGetNode(connection.fromHandle.nodeId); + if (!sourceNode) { + return null; + } + + const sourceOutputCapacity = + connection.fromHandle.type === 'source' + ? validateSourceOutputCapacity(sourceNode, connection.fromHandle.id, edges) + : { valid: true as const }; + const compatibleOperations = sourceOutputCapacity.valid + ? filterCompatibleAddOperations( + getVisibleAddOperations({ + isTemplateMode: false, + namespace: ROOT_NAMESPACE, + }), + sourceNode, + connection.fromHandle.id, + { + namespace: ROOT_NAMESPACE, + parentId: sourceNode.parentId, + }, + connection.fromHandle.type, + ) + : []; + + let message = + !sourceOutputCapacity.valid + ? sourceOutputCapacity.error + : connection.fromHandle.type === 'target' + ? 'Drop on a compatible output, or release on empty space to add a compatible previous operation.' + : 'Drop on a compatible input, or release on empty space to add a compatible next operation.'; + let tone: 'info' | 'success' | 'error' = sourceOutputCapacity.valid + ? 'info' + : 'error'; + + if (connection.toHandle && connection.toNode) { + const result = validateConnectionSimple( + createConnectionFromDraggedHandle({ + fromNodeId: connection.fromHandle.nodeId, + fromHandleId: connection.fromHandle.id, + fromHandleType: connection.fromHandle.type, + otherNodeId: connection.toHandle.nodeId, + otherHandleId: connection.toHandle.id, + }), + nodeManager, + edges, + ); + + if (result.valid) { + tone = 'success'; + message = `Compatible target: ${connection.toNode.type}`; + } else { + tone = 'error'; + message = result.error; + } + } + + return ( + + + + Connection Helper + {message} + + {connection.fromHandle.type === 'target' + ? 'Compatible previous operations available: ' + : 'Compatible next operations available: '} + {compatibleOperations.length} + + + + + ); +} diff --git a/diagram-editor/frontend/diagram-editor.tsx b/diagram-editor/frontend/diagram-editor.tsx index a4628b8c..59429c9f 100644 --- a/diagram-editor/frontend/diagram-editor.tsx +++ b/diagram-editor/frontend/diagram-editor.tsx @@ -30,6 +30,7 @@ import { inflateSync, strFromU8 } from 'fflate'; import React, { Suspense } from 'react'; import AddOperation from './add-operation'; import CommandPanel from './command-panel'; +import { ConnectionHintPanel } from './connection-hint-panel'; import type { DiagramEditorEdge } from './edges'; import { createBaseEdge, @@ -47,7 +48,7 @@ import { ExportDiagramDialog } from './export-diagram-dialog'; import { defaultEdgeData, EditEdgeForm, EditNodeForm } from './forms'; import EditScopeForm from './forms/edit-scope-form'; import { type LoadContext, LoadContextProvider } from './load-context-provider'; -import { type DiagramProperties, DiagramPropertiesProvider } from './diagram-properties-provider'; +import { DiagramPropertiesProvider } from './diagram-properties-provider'; import { NodeManager, NodeManagerProvider } from './node-manager'; import { type DiagramEditorNode, @@ -62,9 +63,11 @@ import { EdgesProvider } from './use-edges'; import { autoLayout } from './utils/auto-layout'; import { isRemoveChange } from './utils/change'; import { + createConnectionFromDraggedHandle, getValidEdgeTypes, - validateConnectionQuick, + validateConnectionSimple, validateEdgeSimple, + validateSourceOutputCapacity, } from './utils/connection'; import { exhaustiveCheck } from './utils/exhaustive-check'; import { exportTemplate } from './utils/export-diagram'; @@ -151,6 +154,7 @@ function DiagramEditor() { DiagramEditorNode, DiagramEditorEdge > | null>(null); + const suppressNextPaneClick = React.useRef(false); const [editorMode, setEditorMode] = React.useState({ mode: EditorMode.Normal, @@ -392,10 +396,16 @@ function DiagramEditor() { open: boolean; popOverPosition: PopoverPosition; parentId: string | null; + sourceConnection: { + sourceNodeId: string; + sourceHandle: string | null; + sourceHandleType: 'source' | 'target'; + } | null; }>({ open: false, popOverPosition: { left: 0, top: 0 }, parentId: null, + sourceConnection: null, }); const addOperationNewNodePosition = React.useMemo(() => { if (!reactFlowInstance.current) { @@ -455,7 +465,11 @@ function DiagramEditor() { const closeAllPopovers = React.useCallback(() => { setEditingNodeId(null); setEditingEdgeId(null); - setAddOperationPopover((prev) => ({ ...prev, open: false })); + setAddOperationPopover((prev) => ({ + ...prev, + open: false, + sourceConnection: null, + })); setEditOpFormPopoverProps({ open: false }); }, []); @@ -488,6 +502,7 @@ function DiagramEditor() { open: true, popOverPosition: { left: ev.clientX, top: ev.clientY }, parentId: node.id, + sourceConnection: null, }); }} /> @@ -551,10 +566,36 @@ function DiagramEditor() { mouseDownTime.current = Date.now(); }, []); + const getClientPosition = React.useCallback( + (event: MouseEvent | TouchEvent): XYPosition | null => { + if ('clientX' in event) { + return { x: event.clientX, y: event.clientY }; + } + + const touch = event.changedTouches[0] || event.touches[0]; + if (!touch) { + return null; + } + + return { x: touch.clientX, y: touch.clientY }; + }, + [], + ); + const tryCreateEdge = React.useCallback( - (conn: Connection, id?: string): DiagramEditorEdge | null => { - const sourceNode = nodeManager.tryGetNode(conn.source); - const targetNode = nodeManager.tryGetNode(conn.target); + ( + conn: Connection, + id?: string, + nodeOverride?: DiagramEditorNode, + ): DiagramEditorEdge | null => { + const sourceNode = + nodeOverride?.id === conn.source + ? nodeOverride + : nodeManager.tryGetNode(conn.source); + const targetNode = + nodeOverride?.id === conn.target + ? nodeOverride + : nodeManager.tryGetNode(conn.target); if (!sourceNode || !targetNode) { throw new Error('cannot find source or target node'); } @@ -598,7 +639,17 @@ function DiagramEditor() { } } - const validationResult = validateEdgeSimple(newEdge, nodeManager, edges); + const validationNodeManager = nodeOverride + ? new NodeManager([ + ...nodeManager.nodes.filter((node) => node.id !== nodeOverride.id), + nodeOverride, + ]) + : nodeManager; + const validationResult = validateEdgeSimple( + newEdge, + validationNodeManager, + edges, + ); if (!validationResult.valid) { showErrorToast(validationResult.error); return null; @@ -666,7 +717,7 @@ function DiagramEditor() { } }} isValidConnection={(conn) => { - return validateConnectionQuick(conn, nodeManager).valid; + return validateConnectionSimple(conn, nodeManager, edges).valid; }} onReconnect={(oldEdge, newConnection) => { const newEdge = tryCreateEdge(newConnection, oldEdge.id); @@ -676,6 +727,64 @@ function DiagramEditor() { setEdges((prev) => reconnectEdge(oldEdge, newConnection, prev)); } }} + onConnectEnd={(event, connectionState) => { + if (!connectionState.fromHandle) { + return; + } + + if (connectionState.isValid === false && connectionState.toHandle) { + const result = validateConnectionSimple( + createConnectionFromDraggedHandle({ + fromNodeId: connectionState.fromHandle.nodeId, + fromHandleId: connectionState.fromHandle.id, + fromHandleType: connectionState.fromHandle.type, + otherNodeId: connectionState.toHandle.nodeId, + otherHandleId: connectionState.toHandle.id, + }), + nodeManager, + edges, + ); + + if (!result.valid) { + showErrorToast(result.error); + } + return; + } + + if (connectionState.toHandle || connectionState.isValid) { + return; + } + + const sourceNode = nodeManager.tryGetNode(connectionState.fromHandle.nodeId); + const clientPosition = getClientPosition(event); + if (!sourceNode || !clientPosition) { + return; + } + + if (connectionState.fromHandle.type === 'source') { + const outputCapacity = validateSourceOutputCapacity( + sourceNode, + connectionState.fromHandle.id, + edges, + ); + if (!outputCapacity.valid) { + showErrorToast(outputCapacity.error); + return; + } + } + + setAddOperationPopover({ + open: true, + popOverPosition: { left: clientPosition.x, top: clientPosition.y }, + parentId: sourceNode.parentId || null, + sourceConnection: { + sourceNodeId: sourceNode.id, + sourceHandle: connectionState.fromHandle.id || null, + sourceHandleType: connectionState.fromHandle.type, + }, + }); + suppressNextPaneClick.current = true; + }} onNodeClick={(ev, node) => { ev.stopPropagation(); closeAllPopovers(); @@ -710,6 +819,11 @@ function DiagramEditor() { }); }} onPaneClick={(ev) => { + if (suppressNextPaneClick.current) { + suppressNextPaneClick.current = false; + return; + } + if (addOperationPopover.open || editOpFormPopoverProps.open) { closeAllPopovers(); return; @@ -724,6 +838,7 @@ function DiagramEditor() { open: true, popOverPosition: { left: ev.clientX, top: ev.clientY }, parentId: null, + sourceConnection: null, }); }} onMouseDownCapture={handleMouseDown} @@ -741,6 +856,7 @@ function DiagramEditor() { {editorMode.templateId} )} + - setAddOperationPopover((prev) => ({ ...prev, open: false })) - } + onClose={closeAllPopovers} anchorReference="anchorPosition" anchorPosition={addOperationPopover.popOverPosition} // use a custom component to prevent the popover from creating an invisible element that blocks clicks @@ -784,8 +898,36 @@ function DiagramEditor() { { + sourceConnection={addOperationPopover.sourceConnection} + onAdd={({ changes, primaryNodeId }) => { handleNodeChanges(changes); + if (addOperationPopover.sourceConnection) { + const targetNode = + changes.find((change) => change.item.id === primaryNodeId)?.item || + null; + if (targetNode) { + const newEdge = tryCreateEdge( + addOperationPopover.sourceConnection.sourceHandleType === 'source' + ? { + source: addOperationPopover.sourceConnection.sourceNodeId, + sourceHandle: addOperationPopover.sourceConnection.sourceHandle, + target: targetNode.id, + targetHandle: null, + } + : { + source: targetNode.id, + sourceHandle: null, + target: addOperationPopover.sourceConnection.sourceNodeId, + targetHandle: addOperationPopover.sourceConnection.sourceHandle, + }, + undefined, + targetNode, + ); + if (newEdge) { + setEdges((prev) => addEdge(newEdge, prev)); + } + } + } closeAllPopovers(); }} /> diff --git a/diagram-editor/frontend/forms/buffer-edge-form.tsx b/diagram-editor/frontend/forms/buffer-edge-form.tsx index efaa89b6..9741b44b 100644 --- a/diagram-editor/frontend/forms/buffer-edge-form.tsx +++ b/diagram-editor/frontend/forms/buffer-edge-form.tsx @@ -68,7 +68,7 @@ export function BufferEdgeInputForm({ if (typeof targetNode.data.op.builder === 'string') { const sectionRegistration = registry.sections[targetNode.data.op.builder]; return sectionRegistration - ? Object.keys(sectionRegistration.metadata.buffers) + ? Object.keys(sectionRegistration.interface.buffers) : []; } else if (typeof targetNode.data.op.template === 'string') { const template = templates[targetNode.data.op.template]; diff --git a/diagram-editor/frontend/forms/data-input-form.tsx b/diagram-editor/frontend/forms/data-input-form.tsx index a5d8ed54..f210b4a8 100644 --- a/diagram-editor/frontend/forms/data-input-form.tsx +++ b/diagram-editor/frontend/forms/data-input-form.tsx @@ -37,7 +37,7 @@ export function DataInputForm({ edge, onChange }: DataInputEdgeFormProps) { if (typeof targetNode.data.op.builder === 'string') { const sectionBuilder = registry.sections[targetNode.data.op.builder]; - return Object.keys(sectionBuilder?.metadata.inputs || {}); + return Object.keys(sectionBuilder?.interface.inputs || {}); } else if (typeof targetNode.data.op.template === 'string') { const template = templates[targetNode.data.op.template]; return template ? getTemplateInputs(template) : []; diff --git a/diagram-editor/frontend/forms/section-form.tsx b/diagram-editor/frontend/forms/section-form.tsx index ec770553..a58db85d 100644 --- a/diagram-editor/frontend/forms/section-form.tsx +++ b/diagram-editor/frontend/forms/section-form.tsx @@ -231,7 +231,7 @@ export function SectionEdgeForm({ edge, onChange }: SectionEdgeFormProps) { if (sourceNode && isSectionNode(sourceNode)) { if (typeof sourceNode.data.op.builder === 'string') { const sectionBuilder = registry.sections[sourceNode.data.op.builder]; - return Object.keys(sectionBuilder?.metadata.outputs || {}); + return Object.keys(sectionBuilder?.interface.outputs || {}); } else if (typeof sourceNode.data.op.template === 'string') { const template = templates[sourceNode.data.op.template]; return template?.outputs || []; diff --git a/diagram-editor/frontend/handles.tsx b/diagram-editor/frontend/handles.tsx index a2082986..89011e64 100644 --- a/diagram-editor/frontend/handles.tsx +++ b/diagram-editor/frontend/handles.tsx @@ -1,7 +1,15 @@ import { Handle as ReactFlowHandle, type HandleProps as ReactFlowHandleProps, + useConnection, + useNodeId, } from '@xyflow/react'; +import { useNodeManager } from './node-manager'; +import { + createConnectionFromDraggedHandle, + validateConnectionSimple, +} from './utils/connection'; +import { useEdges } from './use-edges'; import { exhaustiveCheck } from './utils/exhaustive-check'; export enum HandleId { @@ -53,11 +61,52 @@ function variantClassName(handleType?: HandleType): string | undefined { } export function Handle({ id, variant, className, ...baseProps }: HandleProps) { - const prependClassName = className - ? `${variantClassName(variant)} ${className} ` - : variantClassName(variant); + const nodeId = useNodeId(); + const nodeManager = useNodeManager(); + const edges = useEdges(); + const connection = useConnection(); + const handleType = baseProps.type || 'source'; + + const classNames: string[] = []; + const variantClass = variantClassName(variant); + if (variantClass) { + classNames.push(variantClass); + } + if (className) { + classNames.push(className); + } + + if ( + nodeId && + connection.inProgress && + connection.fromHandle && + connection.fromHandle.nodeId !== nodeId && + connection.fromHandle.type !== handleType + ) { + const conn = createConnectionFromDraggedHandle({ + fromNodeId: connection.fromHandle.nodeId, + fromHandleId: connection.fromHandle.id, + fromHandleType: connection.fromHandle.type, + otherNodeId: nodeId, + otherHandleId: id, + }); + + const result = validateConnectionSimple( + conn, + nodeManager, + edges, + ); + + if (result.valid) { + classNames.push('handle-compatible'); + } + } return ( - + 0 ? classNames.join(' ') : undefined} + /> ); } diff --git a/diagram-editor/frontend/nodes/test-utils.tsx b/diagram-editor/frontend/nodes/test-utils.tsx index acf3d099..27314f35 100644 --- a/diagram-editor/frontend/nodes/test-utils.tsx +++ b/diagram-editor/frontend/nodes/test-utils.tsx @@ -54,15 +54,20 @@ export function render( registry?: DiagramElementMetadata, options?: Omit, ) { - registry = registry || { - messages: {}, + const resolvedRegistry = registry || { + messages: [], nodes: {}, + reverse_message_lookup: { + result: [], + split: [], + unzip: [], + }, schemas: {}, sections: {}, trace_supported: false, }; return baseRender(ui, { - wrapper: createTestingProviders(registry), + wrapper: createTestingProviders(resolvedRegistry), ...options, }); } diff --git a/diagram-editor/frontend/registry-provider.tsx b/diagram-editor/frontend/registry-provider.tsx index c39e707e..401b8318 100644 --- a/diagram-editor/frontend/registry-provider.tsx +++ b/diagram-editor/frontend/registry-provider.tsx @@ -9,12 +9,12 @@ import { import { timer } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { useApiClient } from './api-client-provider'; -import type { DiagramElementMetdata } from './types/api'; +import type { DiagramElementMetadata } from './types/api'; -const RegistryContextComp = createContext(null); +const RegistryContextComp = createContext(null); export const RegistryProvider = ({ children }: PropsWithChildren) => { - const [registry, setRegistry] = useState(null); + const [registry, setRegistry] = useState(null); const [showLoading, setShowLoading] = useState(false); const [error, setError] = useState(null); const apiClient = useApiClient(); diff --git a/diagram-editor/frontend/run-button.tsx b/diagram-editor/frontend/run-button.tsx index 1e172ae5..f963ba0d 100644 --- a/diagram-editor/frontend/run-button.tsx +++ b/diagram-editor/frontend/run-button.tsx @@ -115,8 +115,13 @@ export function RunButton({ requestJsonString }: RunButtonProps) { slotProps={{ paper: { sx: { - overflow: 'visible', + overflow: 'hidden', mt: 0.5, + width: 'min(560px, calc(100vw - 32px))', + maxWidth: 'calc(100vw - 32px)', + maxHeight: 'calc(100vh - 32px)', + display: 'flex', + flexDirection: 'column', backgroundColor: theme.palette.background.paper, border: `1px solid ${theme.palette.divider}`, '&:before': { @@ -138,13 +143,20 @@ export function RunButton({ requestJsonString }: RunButtonProps) { > Run Workflow - + Request: - +