go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/projects/nodes/static/_nextjs/src/app/page.tsx (about) 1 /** 2 * Copyright (c) 2024 - Present. Will Charczuk. All rights reserved. 3 * Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository. 4 */ 5 'use client'; 6 7 import { useRouter, useSearchParams } from 'next/navigation'; 8 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 9 import ReactFlow, { 10 Node as FlowNode, 11 Controls, 12 Background, 13 BackgroundVariant, 14 Edge, 15 ConnectionLineType, 16 ConnectionMode, 17 Viewport, 18 Connection, 19 Node, 20 XYPosition, 21 ReactFlowProvider, 22 OnSelectionChangeParams, 23 } from 'reactflow'; 24 import { Button, HotkeysProvider, OverlayToaster, Position, Section, SectionCard, Spinner, SpinnerSize, Tooltip } from '@blueprintjs/core'; 25 import { shallow } from 'zustand/shallow'; 26 import { useMonaco } from '@monaco-editor/react'; 27 28 import * as api from '../api/nodes'; 29 import * as graphApi from '../api/graphs'; 30 import { HeaderNavbar } from '../components/headerNavbar'; 31 import { NodeCard } from '../components/nodeCard'; 32 import useStateStore, { defaultEdgeOptions } from '../store/store'; 33 import editorTheme from '../components/editorTheme'; 34 import { Omnibar } from '../components/omnibar'; 35 import { DialogAddNode } from '../components/dialogAddNode'; 36 import { DialogEditNode } from '../components/dialogEditNode'; 37 import { nodeTypes } from '../refdata/nodeTypes'; 38 import { DialogLink } from '../components/dialogLink'; 39 import { DialogLoadFile } from '../components/dialogLoadFile'; 40 import { StoreState } from '../store/store' 41 import { ApiEffect } from '../store/apiEffect'; 42 import { NodeData } from '../store/nodeData'; 43 import { NodeContextMenu, NodeContextMenuProps } from '../components/nodeContextMenu'; 44 import { EditTableDrawer } from '../components/editTableDrawer'; 45 import { FlowPaneContextMenu, FlowPaneContextMenuProps } from '../components/flowPaneContextMenu'; 46 import { DialogLogs } from '../components/dialogLogs'; 47 import { SessionProvider } from '../components/sessionProvider'; 48 import { DialogAddGraph } from '../components/dialogAddGraph'; 49 import { DialogEditGraph } from '../components/dialogEditGraph'; 50 import { DialogManageGraphs } from '../components/dialogManageGraphs'; 51 52 export default function Page() { 53 const searchParams = useSearchParams(); 54 const graphId = searchParams.get('graph_id'); 55 return ( 56 <SessionProvider> 57 <ReactFlowProvider> 58 <GraphEditor searchGraphId={graphId} /> 59 </ReactFlowProvider> 60 </SessionProvider> 61 ) 62 } 63 64 const nodeCardTypes = { 65 nodeCard: NodeCard, 66 } 67 68 const stateSelector = (state: StoreState) => ({ 69 isLoading: state.isLoading, 70 nodes: state.nodes, 71 watchedNodes: state.watchedNodes, 72 edges: state.edges, 73 graph: state.graph, 74 graphs: state.graphs, 75 onApiEffect: state.onApiEffect, 76 onDisconnect: state.onDisconnect, 77 onConnect: state.onConnect, 78 onNodesChange: state.onNodesChange, 79 onEdgesChange: state.onEdgesChange, 80 onRefresh: state.onRefresh, 81 onStabilize: state.onStabilize, 82 applyNodeDataChangeToStore: state.applyNodeDataChangeToStore, 83 }); 84 85 function GraphEditor({ searchGraphId }: { searchGraphId: string | null }) { 86 const router = useRouter(); 87 88 const hasGraphId = searchGraphId !== null; 89 const graphId = searchGraphId || ''; 90 91 const edgeUpdateSuccessful = useRef(true); 92 const dragRef = useRef<FlowNode | null>(null); 93 const viewportMoveRef = useRef<Viewport | null>(null); 94 const viewportRef = useRef<Viewport | null>(null); 95 const alertRef = useRef<OverlayToaster>(null); 96 const selectionRef = useRef<OnSelectionChangeParams | null>(null); 97 const { 98 isLoading, nodes, watchedNodes, edges, graph, graphs, 99 onNodesChange, onEdgesChange, onDisconnect, onConnect, onRefresh, 100 onApiEffect, onStabilize, applyNodeDataChangeToStore 101 } = useStateStore(stateSelector, shallow); 102 103 const [watchedNodeValues, setWatchedNodeValues] = useState<{ [key: string]: any }>({}); 104 const [stabilizing, setStabilizing] = React.useState(false); 105 106 const [dialogAddNodeOpen, setDialogAddNodeOpen] = useState(false); 107 const [dialogEditNodeOpen, setDialogEditNodeOpen] = useState(false); 108 const [dialogAddGraphOpen, setDialogAddGraphOpen] = useState(false); 109 const [dialogEditGraphOpen, setDialogEditGraphOpen] = useState(false); 110 const [dialogManageGraphsOpen, setDialogManageGraphsOpen] = useState(false); 111 const [tableEditorOpen, setTableEditorOpen] = useState(false); 112 const [dialogLoadFileOpen, setDialogLoadFileOpen] = React.useState(false); 113 const [dialogLogsOpen, setDialogLogsOpen] = React.useState(false); 114 const [dialogLinkOpen, setDialogLinkOpen] = React.useState(false); 115 116 const [omnibarOpen, setOmnibarOpen] = React.useState(false); 117 const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextMenuProps | null>(null); 118 const [flowPaneContextMenu, setFlowPaneContextMenu] = useState<FlowPaneContextMenuProps | null>(null); 119 120 const [logs, setLogs] = useState<string | undefined>(); 121 122 const [showAddGraph, setShowAddGraph] = useState(false); 123 const [addNodeType, setAddNodeType] = useState<string | null>(null); 124 const [addNodePosition, setAddNodePosition] = useState<XYPosition | undefined>(); 125 const [editNode, setEditNode] = useState<api.Node>(api.emptyNode); 126 const [editValue, setEditValue] = useState<api.NativeValueType>(''); 127 const [editGraph, setEditGraph] = useState<graphApi.Graph>(graphApi.emptyGraph); 128 129 const handleError = (e) => { 130 alertRef.current?.show({ 131 message: e.message, 132 intent: 'danger', 133 }) 134 } 135 136 const onConnectWithError = useCallback((connection: Connection) => { 137 onConnect(connection).catch(handleError) 138 }, []) 139 140 const doStabilize = useCallback(() => { 141 setStabilizing(true) 142 onStabilize().then(() => { 143 setStabilizing(false) 144 }).catch(e => { 145 handleError(e) 146 setStabilizing(false) 147 }) 148 }, []); 149 150 const doWatch = useCallback(async (nodeId: string, watched: boolean) => { 151 try { 152 await onApiEffect([{ 153 type: 'update-watched', 154 node_id: nodeId, 155 watched: watched, 156 }]) 157 } catch (err) { 158 handleError(err) 159 } 160 }, []); 161 162 const doWatchedCollapsed = useCallback(async (nodeId: string, watched_collapsed: boolean) => { 163 try { 164 await onApiEffect([{ 165 type: 'update-watched-collapsed', 166 node_id: nodeId, 167 watched_collapsed: watched_collapsed, 168 }]) 169 } catch (err) { 170 handleError(err) 171 } 172 }, []); 173 174 const doObserve = useCallback(async (n: Node<NodeData>) => { 175 try { 176 const [nodeId] = await onApiEffect([{ 177 type: 'add-node', 178 node: { 179 ...n.data.node, 180 label: n.data.node.label + '-observer', 181 metadata: { 182 ...n.data.node.metadata, 183 node_type: 'observer', 184 position_x: n.position.x + 400, 185 position_y: n.position.y, 186 input_type: n.data.node.metadata.output_type, 187 output_type: undefined, 188 } 189 }, 190 }]) 191 await onApiEffect([{ 192 type: 'link-nodes', 193 child_id: nodeId, 194 parent_id: n.data.node.id, 195 child_input_name: 'input', 196 }]) 197 } catch (err) { 198 handleError(err) 199 } 200 }, []); 201 202 const doDuplicate = useCallback(async (n: Node<NodeData>) => { 203 onApiEffect([{ 204 type: 'duplicate-node', 205 node: n.data.node, 206 }]).catch(handleError) 207 }, []) 208 209 const doSetStale = useCallback((n: Node<NodeData>) => { 210 onApiEffect([{ 211 type: 'set-stale', 212 node_id: n.id, 213 }]).catch(handleError) 214 }, []) 215 216 const doAddNode = useCallback((nodeType: string, node: api.Node, value: any) => { 217 const clone = Object.assign({}, node); 218 clone.metadata.node_type = nodeType; 219 onApiEffect([{ 220 type: 'add-node', 221 node: clone, 222 value: value, 223 }]).then(() => { 224 setDialogAddNodeOpen(false); 225 }).catch(handleError) 226 }, []) 227 228 const doRemove = useCallback((n: Node<NodeData>) => { 229 onApiEffect([{ 230 type: 'remove-node', 231 node_id: n.data.node.id, 232 }]).catch(handleError) 233 }, []) 234 235 const doCollapse = useCallback((fn: Node<NodeData>, collapsed: boolean) => { 236 fn.data.onCollapse(collapsed) 237 }, []) 238 239 const doCollapseAll = useCallback((collapsed: boolean) => { 240 onApiEffect([{ 241 type: 'update-collapsed-all', 242 collapsed: collapsed, 243 }]) 244 }, []); 245 246 const onAdd = useCallback((nodeType: string | null, xy?: XYPosition) => { 247 setAddNodeType(nodeType) 248 setAddNodePosition(xy) 249 setDialogAddNodeOpen(true) 250 }, []) 251 252 const onShowLink = useCallback(() => { 253 setDialogLinkOpen(true) 254 }, []) 255 256 const onShowGraphLogs = useCallback(() => { 257 graphApi.getGraphLogs(graph?.id || '').then(logs => { 258 setLogs(logs) 259 setDialogLogsOpen(true) 260 }).catch(handleError) 261 }, [graph]) 262 263 const onAddGraph = useCallback(() => { 264 setDialogAddGraphOpen(true) 265 }, []); 266 267 const onShowGraphs = useCallback(() => { 268 setDialogManageGraphsOpen(true) 269 }, []); 270 271 const onSave = useCallback(() => { 272 if (graph !== null) { 273 window.location.href = `/api/v1/graph/${graph.id}/save/` 274 } 275 }, [graph]); 276 277 const onLoad = useCallback(() => { 278 setDialogLoadFileOpen(true); 279 }, []) 280 281 const onLoadClose = useCallback(() => { 282 setDialogLoadFileOpen(false); 283 }, []) 284 285 const onLogsClose = useCallback(() => { 286 setDialogLogsOpen(false); 287 }, []) 288 289 const onEdit = useCallback((n: Node<NodeData>) => { 290 setEditNode(n.data.node); 291 if (n.data.node.metadata.node_type === 'table') { 292 setTableEditorOpen(true) 293 } else { 294 const nodeType = nodeTypes[n.data.node.metadata.node_type]; 295 if (nodeType.canSetValue) { 296 api.getNodeValue(graph?.id || '', n.id).then((v) => { 297 setEditValue(v) 298 setDialogEditNodeOpen(true) 299 }).catch(handleError) 300 } else { 301 setDialogEditNodeOpen(true) 302 } 303 } 304 }, [graph]) 305 306 const onEditSave = useCallback((n: api.Node, value: any) => { 307 const nodeType = nodeTypes[n.metadata.node_type] 308 const effects: ApiEffect[] = []; 309 effects.push({ type: 'update-label', node_id: n.id, label: n.label }); 310 if (nodeType.canSetExpression) { 311 if (n.metadata.expression === undefined) { 312 throw new Error('expression is required'); 313 } 314 effects.push({ type: 'update-expression', node_id: n.id, expression: n.metadata.expression }); 315 } 316 if (nodeType.canSetValue) { 317 if (value === undefined) { 318 throw new Error('value is required'); 319 } 320 effects.push({ type: 'update-value', node_id: n.id, value: value }); 321 } 322 onApiEffect(effects).then(() => { 323 setDialogEditNodeOpen(false); 324 }).catch(handleError) 325 }, [graph]) 326 327 const onEditGraphSave = useCallback((g: graphApi.Graph) => { 328 onApiEffect([{ 329 type: 'update-graph-label', 330 graph_id: g.id, 331 label: g.label, 332 }]).then(() => { 333 setDialogEditGraphOpen(false) 334 }).catch(handleError) 335 }, [graph]); 336 337 const doEditGraph = useCallback(async (graphId: string) => { 338 const graph = await graphApi.getGraph(graphId); 339 setEditGraph(graph) 340 setDialogEditGraphOpen(true) 341 }, []) 342 343 const doDeleteGraph = useCallback(async (graphId: string) => { 344 onApiEffect([{ type: 'delete-graph', graph_id: graphId }]).catch(handleError) 345 }, []); 346 347 const doNodeLabelChange = useCallback((n: api.Node, label: string) => { 348 onApiEffect([{ type: 'update-label', node_id: n.id, label: label }]).catch(handleError) 349 }, []); 350 351 const onLink = useCallback((conn: Connection) => { 352 onApiEffect([{ 353 type: 'link-nodes', 354 parent_id: conn.source || '', 355 child_id: conn.target || '', 356 child_input_name: conn.targetHandle, 357 }]).then(() => setDialogLinkOpen(false)).catch(handleError) 358 }, []); 359 360 const submitFile = useCallback((loadedFiles?: FileList) => { 361 if (loadedFiles) { 362 graphApi.postGraphLoad(loadedFiles).then(() => { 363 setDialogLoadFileOpen(false); 364 onRefresh(graphId); 365 }).catch(handleError) 366 } 367 }, []); 368 369 const onFlowEdgeUpdateStart = useCallback(() => { 370 edgeUpdateSuccessful.current = false; 371 }, []); 372 const onFlowEdgeUpdate = useCallback(() => { 373 edgeUpdateSuccessful.current = true; 374 }, []); 375 const onFlowEdgeUpdateEnd = useCallback((e: MouseEvent, edge: Edge<any>) => { 376 if (!edgeUpdateSuccessful.current) { 377 onDisconnect(edge).catch(handleError) 378 } 379 edgeUpdateSuccessful.current = true; 380 }, []); 381 382 const onFlowNodeDragStart = useCallback((event: any, node: FlowNode<NodeData>, nodes: FlowNode<NodeData>[]) => { 383 dragRef.current = node; 384 }, []); 385 const onFlowNodeDragStop = useCallback((_, node: FlowNode) => { 386 if (dragRef.current) { 387 if (dragRef.current.position !== node.position) { 388 onApiEffect([{ 389 type: 'update-position', 390 node_id: node.id, 391 position: node.position 392 }]).catch(handleError) 393 } 394 } 395 dragRef.current = null; 396 }, []) 397 398 const onMoveStart = useCallback((event: MouseEvent | TouchEvent, viewport: Viewport) => { 399 setNodeContextMenu(null) 400 viewportMoveRef.current = viewport; 401 }, []); 402 403 const onMoveEnd = useCallback((event: MouseEvent | TouchEvent, viewport: Viewport) => { 404 if (viewportMoveRef.current) { 405 if (viewportMoveRef.current !== viewport) { 406 onApiEffect([{ 407 type: 'update-graph-viewport', 408 graph_id: graph?.id || '', 409 viewport: viewport, 410 }]).catch(handleError) 411 } 412 } 413 viewportMoveRef.current = null; 414 }, [graph]); 415 416 const onMove = useCallback((event: MouseEvent | TouchEvent, viewport: Viewport) => { 417 viewportRef.current = viewport; 418 }, []); 419 420 // onFitView is just the action handler that is called _after_ 421 // the viewport is changed and `onMove` fires, so we have to tap 422 // into the viewport ref we manage in the `onMove` handler. 423 const onFitView = useCallback(() => { 424 if (viewportRef.current) { 425 onApiEffect([{ 426 type: 'update-graph-viewport', 427 graph_id: graph?.id || '', 428 viewport: viewportRef.current, 429 }]).catch(handleError) 430 } 431 }, [graph, viewportRef]) 432 433 const fetchWatchedValuesFromAPI = () => { 434 const ids = watchedNodes.map((o) => o.id); 435 if (ids.length > 0) { 436 api.getNodeValues(graph?.id || '', ids).then((nodeValues) => { 437 setWatchedNodeValues(nodeValues.reduce((o, v) => { 438 o[v.id] = v 439 return o 440 }, {})) 441 }).catch(handleError) 442 } 443 } 444 445 const onNodeContextMenu = useCallback( 446 (event, node) => { 447 event.preventDefault(); 448 setNodeContextMenu({ 449 node: node, 450 top: event.clientY - 50, 451 left: event.clientX, 452 onDuplicate: doDuplicate, 453 onWatch: doWatch, 454 onObserve: doObserve, 455 onEdit: onEdit, 456 onRemove: doRemove, 457 onSetStale: doSetStale, 458 }); 459 }, 460 [graph, setNodeContextMenu] 461 ); 462 463 const getProjectViewport = (): Viewport => { 464 if (viewportRef.current) { 465 return viewportRef.current 466 } 467 return graphApi.viewportFromGraph(graph); 468 } 469 470 const project = (pos: XYPosition): XYPosition => { 471 const { x, y, zoom } = getProjectViewport(); 472 return { 473 x: (pos.x - x) / zoom, 474 y: (pos.y - y) / zoom, 475 }; 476 } 477 478 const onFlowPaneContextMenu = useCallback( 479 (event) => { 480 event.preventDefault(); 481 const projectedXY = project({ x: event.clientX, y: event.clientY }); 482 setFlowPaneContextMenu({ 483 top: event.clientY, 484 left: event.clientX, 485 onAddNode: () => { 486 onAdd(null, projectedXY) 487 }, 488 onRefresh: () => { onRefresh(graphId) }, 489 onStabilize: onStabilize, 490 }) 491 }, 492 [setFlowPaneContextMenu] 493 ) 494 495 const onSelectionChange = (params: OnSelectionChangeParams) => { 496 selectionRef.current = params 497 } 498 499 const doSelectionRemove = () => { 500 const effects: Array<ApiEffect> = [] 501 if (selectionRef.current) { 502 for (const node of selectionRef.current.nodes) { 503 effects.push({ 504 type: 'remove-node', 505 node_id: node.id, 506 }) 507 } 508 } 509 onApiEffect(effects).catch(handleError) 510 } 511 512 const onEditTableNodeChange = (n: api.Node) => { 513 applyNodeDataChangeToStore(n.id, (n0d: NodeData): NodeData => { 514 n0d.refreshedAt = new Date() 515 return n0d 516 }) 517 } 518 519 const doSelectionDuplicate = () => { 520 const effects: Array<ApiEffect> = [] 521 if (selectionRef.current) { 522 for (const node of selectionRef.current.nodes) { 523 effects.push({ 524 type: 'duplicate-node', 525 node: node.data.node, 526 }) 527 } 528 } 529 onApiEffect(effects).catch(handleError) 530 } 531 532 const doAddGraph = async (g: graphApi.Graph) => { 533 const newGraphId = await graphApi.postGraph(g); 534 router.push(`/?graph_id=${newGraphId}`) 535 setDialogAddGraphOpen(false); 536 } 537 538 // Close the context menu if it's open whenever the window is clicked. 539 const onFlowPaneClick = useCallback(() => { 540 setNodeContextMenu(null) 541 setFlowPaneContextMenu(null) 542 }, [setNodeContextMenu, setFlowPaneContextMenu]); 543 544 545 const suppress = (e) => { 546 e.preventDefault(); 547 e.stopPropagation(); 548 } 549 550 const monaco = useMonaco(); 551 552 useEffect(() => { 553 if (monaco) { 554 monaco.editor.defineTheme("bp5", editorTheme); 555 } 556 }, [monaco]) 557 558 useEffect(() => { 559 if (!hasGraphId) { 560 graphApi.getGraphActive().then(graph => { 561 if (graph === null) { 562 setShowAddGraph(true); 563 return 564 } 565 setShowAddGraph(false); 566 router.push(`/?graph_id=${graph.id}`) 567 }) 568 } 569 }, [searchGraphId]); 570 571 useEffect(() => { 572 if (hasGraphId) { 573 onRefresh(graphId) 574 } 575 }, [searchGraphId]) 576 577 // only if we stabilize 578 useEffect(() => { 579 fetchWatchedValuesFromAPI() 580 }, [searchGraphId, watchedNodes, graph?.stabilization_num]) 581 582 useEffect(() => { 583 if (graph) { 584 document.title = `Nodes - ${graph.label}`; 585 } 586 }, [graph]) 587 588 if (!hasGraphId) { 589 return <Spinner size={SpinnerSize.LARGE} style={{ position: 'absolute', left: '50%', top: '50%' }} /> 590 } 591 592 if (showAddGraph || graph === null) { 593 // the user does not have an active graph, it's likely they have _no_ graphs. 594 return ( 595 <div id="app-page"> 596 <DialogAddGraph isOpen={true} canCancel={false} onSave={doAddGraph} isBootstrap={true} /> 597 </div> 598 ) 599 } 600 601 if (isLoading) { 602 return <Spinner size={SpinnerSize.LARGE} style={{ position: 'absolute', left: '50%', top: '50%' }} /> 603 } 604 605 return ( 606 <HotkeysProvider> 607 <div id="app-page"> 608 <HeaderNavbar 609 graph={graph} 610 stabilizing={stabilizing} 611 onAdd={() => onAdd(null)} 612 onRemove={doSelectionRemove} 613 onLink={onShowLink} 614 onDuplicate={doSelectionDuplicate} 615 onStabilize={doStabilize} 616 onRefresh={() => onRefresh(graphId)} 617 onOmnibar={() => setOmnibarOpen(true)} 618 onLoad={onLoad} 619 onSave={onSave} 620 onAddGraph={onAddGraph} 621 onShowGraphs={onShowGraphs} 622 onShowGraphLogs={onShowGraphLogs} 623 /> 624 <Omnibar nodes={nodes} 625 isOpen={omnibarOpen} 626 onClose={() => setOmnibarOpen(false)} 627 onAdd={onAdd} 628 onLink={onShowLink} 629 onEdit={onEdit} 630 onDuplicate={doDuplicate} 631 onDuplicateSelected={doSelectionDuplicate} 632 onRemove={doRemove} 633 onRemoveSelected={doSelectionRemove} 634 onLoad={onLoad} 635 onSave={onSave} 636 onShowGraphLogs={onShowGraphLogs} 637 onStabilize={doStabilize} 638 onRefresh={() => onRefresh(graphId)} 639 onObserve={doObserve} 640 onSetStale={doSetStale} 641 onCollapseAll={doCollapseAll} 642 onCollapse={doCollapse} /> 643 <OverlayToaster ref={alertRef} usePortal={false} position={Position.TOP} /> 644 <div id="flow-container"> 645 <ReactFlow 646 nodes={nodes} 647 edges={edges} 648 649 defaultEdgeOptions={defaultEdgeOptions} 650 651 onMoveStart={onMoveStart} 652 onMoveEnd={onMoveEnd} 653 onMove={onMove} 654 655 connectionMode={ConnectionMode.Strict} 656 connectionLineType={ConnectionLineType.Step} 657 658 onNodesChange={onNodesChange} 659 onEdgesChange={onEdgesChange} 660 661 onEdgeUpdate={onFlowEdgeUpdate} 662 onEdgeUpdateStart={onFlowEdgeUpdateStart} 663 onEdgeUpdateEnd={onFlowEdgeUpdateEnd} 664 665 onConnect={onConnectWithError} 666 667 onNodeDragStart={onFlowNodeDragStart} 668 onNodeDragStop={onFlowNodeDragStop} 669 670 snapToGrid={true} 671 autoPanOnNodeDrag={true} 672 onNodeClick={suppress} 673 onNodeDoubleClick={suppress} 674 675 selectNodesOnDrag={false} 676 multiSelectionKeyCode={'ShiftLeft'} 677 onSelectionChange={onSelectionChange} 678 679 onNodeContextMenu={onNodeContextMenu} 680 onPaneClick={onFlowPaneClick} 681 onPaneContextMenu={onFlowPaneContextMenu} 682 683 nodeTypes={nodeCardTypes} 684 minZoom={0.25} 685 defaultViewport={graphApi.viewportFromGraph(graph)} 686 > 687 <Controls onFitView={onFitView} position='top-left' /> 688 <Background variant={BackgroundVariant.Dots} gap={24} size={1} /> 689 {nodeContextMenu && <NodeContextMenu {...nodeContextMenu} onClick={onFlowPaneClick} />} 690 {flowPaneContextMenu && <FlowPaneContextMenu {...flowPaneContextMenu} onClick={onFlowPaneClick} />} 691 </ReactFlow> 692 {watchedNodes && watchedNodes.length > 0 && ( 693 <div id="outputs"> 694 <WatchedNodes nodes={watchedNodes} values={watchedNodeValues} onWatch={doWatch} onWatchedCollapsed={doWatchedCollapsed} /> 695 </div> 696 )} 697 </div> 698 <DialogAddNode isOpen={dialogAddNodeOpen} nodeType={addNodeType} nodePosition={addNodePosition} doAddNode={doAddNode} onClose={() => setDialogAddNodeOpen(false)} /> 699 <DialogEditNode isOpen={dialogEditNodeOpen} node={editNode} value={editValue} onClose={() => { setDialogEditNodeOpen(false) }} onSave={onEditSave} /> 700 <DialogAddGraph isOpen={dialogAddGraphOpen} onClose={() => { setDialogAddGraphOpen(false) }} onSave={doAddGraph} isBootstrap={false} canCancel={true} /> 701 <DialogEditGraph isOpen={dialogEditGraphOpen} graph={editGraph} onClose={() => { setDialogEditGraphOpen(false) }} onSave={onEditGraphSave} /> 702 <DialogManageGraphs isOpen={dialogManageGraphsOpen} graphs={graphs} onDelete={doDeleteGraph} onEdit={doEditGraph} onClose={() => setDialogManageGraphsOpen(false)} /> 703 <DialogLink isOpen={dialogLinkOpen} nodes={nodes} onClose={() => { setDialogLinkOpen(false) }} onLink={onLink} /> 704 <DialogLoadFile isOpen={dialogLoadFileOpen} onClose={onLoadClose} onLoad={submitFile} /> 705 <DialogLogs isOpen={dialogLogsOpen} onClose={onLogsClose} logs={logs} /> 706 <EditTableDrawer node={editNode} isOpen={tableEditorOpen} onClose={() => setTableEditorOpen(false)} onError={handleError} onLabelChange={doNodeLabelChange} onNodeChange={onEditTableNodeChange} /> 707 </div> 708 </HotkeysProvider> 709 ) 710 } 711 712 declare interface WatchedNodesProps { 713 nodes: api.Node[]; 714 values: { [key: string]: any }; 715 onWatch: (nodeId: string, watched: boolean) => void; 716 onWatchedCollapsed: (nodeId: string, watched_collapsed: boolean) => void; 717 } 718 719 const WatchedNodes = (props: WatchedNodesProps) => { 720 return ( 721 <div className="watched-nodes-inner"> 722 { 723 props.nodes && props.nodes.length > 0 && props.nodes.map((o, i) => { 724 const value: api.NodeValue | undefined = props.values[o.id]; 725 const hasValue = value !== undefined && value !== null; 726 const nodeType = nodeTypes[o.metadata.node_type]; 727 const valueType = nodeType.outputTypeIsDisplayType ? o.metadata.output_type : o.metadata.input_type; 728 const watched_collapsed = o.metadata.watched_collapsed !== undefined ? o.metadata.watched_collapsed : false; 729 return ( 730 <Section 731 key={i} 732 className="output-card node-observer" 733 title={o.label} 734 compact={true} 735 icon='many-to-one' 736 collapsible={true} 737 collapseProps={{ 738 defaultIsOpen: true, 739 isOpen: !watched_collapsed, 740 onToggle: () => { props.onWatchedCollapsed(o.id, !watched_collapsed) }, 741 }} 742 rightElement={<Tooltip content={"Remove the watch for this node"} intent='danger'><Button minimal={true} icon='delete' onClick={() => props.onWatch(o.id, false)} /></Tooltip>} 743 > 744 <WatchedNode hasValue={hasValue} valueType={valueType || 'string'} value={value?.value} /> 745 </Section> 746 ) 747 }) 748 } 749 </div > 750 ) 751 } 752 753 declare interface WatchedNodeProps { 754 hasValue: boolean; 755 valueType: string; 756 value: any; 757 } 758 759 const WatchedNode = (props: WatchedNodeProps) => { 760 const loadingClass = 'bp5-skeleton'; 761 if (props.hasValue && props.valueType === 'svg') { 762 return (<div className={`observer-value value-type-svg ${!props.hasValue ? loadingClass : ''}`} dangerouslySetInnerHTML={{ __html: String(props.value.thumbnail) }}></div>) 763 } 764 return (<div className={`observer-value ${!props.hasValue ? loadingClass : ''}`}>{props.hasValue ? String(props.value) : '-'}</div>) 765 }