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  }