Skip to content

Latest commit

 

History

History
1282 lines (1138 loc) · 44.7 KB

index.org

File metadata and controls

1282 lines (1138 loc) · 44.7 KB

Reagent Flow

https://img.shields.io/clojars/v/net.clojars.simtech/reagent-flow?include_prereleases&style=flat-square.svg

Usage

You can mostly follow the ReactFlow documentation and be sure to replace “react” with “reagent” and use kebab-casing instead of camelCasing. There are some exceptions.

  • Types are prefixed with Flow and uses the original camelCasing. We recommend using the keyword equivalent instead though, so you could ignore types altogether.
  • The parameters received in node-types & edge-types are unchanged, so if you want to use them you should apply (js->clj props :keywordize-keys true). A nice pattern, is to only rely on the id from the parameters and do look-ups in your state manually.
(defn- custom-node [{:keys [id] :as props}]
     (let [node (flow/get-node-by-id @nodes id)
           data (:data node)]
       [:p (:label data)])
  • Hooks are avoided. You manage state with atoms however you please and there are events to listen for viewport-changes on the main component; reagent-flow.

You can read more about the API at cljdocs.

Please examine the examples below to get a better grasp of the aforementioned differences.

Examples

Custom Nodes

Connect the nodes, pick a color and see the nodes change interactively.

(ns custom-nodes.core
  (:require
   [reagent.core :as r]
   [reagent.dom.client :as rdom]
   [reagent-flow.core
    :refer [add-edge apply-edge-changes apply-node-changes
            background handle reagent-flow node-resizer
            get-connections-by-node-id get-node-by-id]]))

We use atoms to store nodes & edges. The nodes you see here with the types parameter are custom nodes.

(def nodes
  (r/atom [{:id          :explanation
            :connectable false
            :draggable   false
            :selectable  false
            :position    {:x 0 :y 0}
            :data        {:label "Pick a color & connect the nodes"}}
           {:id              :c1
            :type            :color-node
            :class-name      :color-node
            :position        {:x 60 :y 60}
            :data            {:color "#e6d5d0"}
            :source-position :right}
           {:id              :p2
            :type            :preview-node
            :position        {:x 300 :y 300}
            :data            {:label "Preview color"}
            :target-position :left}]))

(def edges
  (r/atom []))

This is the code for the color-node. Note that we use get-node-by-id. This will return the node with an associated index, so that we can make changes to our atom above.

(defn color-node [{:keys [id data]}]
  (let [node          (get-node-by-id @nodes id)
        default-color (-> data :color)]
    (letfn [(handle-change [event]
              (let [color (-> event .-target .-value)
                    path  [(:index node) :data]]
                (swap! nodes update-in path assoc :color color)))]
      (fn [{is-connectable :isConnectable}]
        (let [node  (get-node-by-id @nodes id)
              color (-> node :data :color)]
          [:<>
           [:input {:class         [:nodrag :color-picker] 
                    :type          :color
                    :on-change     handle-change
                    :value         color
                    :default-value default-color}]
           [handle {:type           :source
                    :position       :right
                    :id             :a
                    :is-connectable is-connectable}]])))))

As with the color-node, the preview-node uses the isConnected parameter. Note that it doesn’t follow idiomatic Clojure naming as the rest of ReagentFlow. This is due to ReactFlow calling our function directly. Also note how easy it is to make a node resizable.

(defn preview-node [{id             :id
                     is-connectable :isConnectable
                     selected       :selected}]
  (let [node            (get-node-by-id @nodes id)
        {:keys [label]} (:data node)
        connection      (first (get-connections-by-node-id @edges id))
        source          (get-node-by-id @nodes (:source connection))
        color           (-> source :data :color)]
    [:<>
     [node-resizer {:is-visible selected
                    :min-width  80
                    :min-height 50}]
     [:div {:style (merge {:background-color :white
                           :display          :flex
                           :align-items      :center
                           :justify-content  :center
                           :border-radius    :5px
                           :height           "100%"
                           :padding          :1em}
                          (when connection {:background-color color}))}
      [:strong {:style {:color  color
                        :filter "invert(100%) grayscale(1)"}} label]]
     [handle {:type           :target
              :position       :left
              :id             :b
              :is-connectable is-connectable}]]))

(defonce node-types
  {:color-node   color-node
   :preview-node preview-node})

As with ReactFlow, we need to define our event-handlers outside of it’s render-loop.

(defn- main []
  (letfn [(handle-node-changes [changes]
            (reset! nodes (apply-node-changes changes @nodes)))
          (handle-edge-changes [changes]
            (reset! edges (apply-edge-changes changes @edges)))
          (handle-connect [connection]
            (reset! edges (add-edge connection @edges)))]
    (fn []
      [reagent-flow {:nodes                @nodes
                     :edges                @edges
                     :node-types           node-types
                     :fit-view             true
                     :on-nodes-change      handle-node-changes
                     :on-edges-change      handle-edge-changes
                     :on-connect           handle-connect
                     :connection-line-type :smoothstep
                     :default-edge-options {:animated true
                                            :type     :smoothstep}}
       [background {:style {:background-color "#ffffff"}}]])))
<<init>>

Drop it like it’s hot

Drag & drop nodes from a top panel and onto the graph. Edges can be connected and disconnected per usual.

(ns drop-it-like-its-hot.core
  (:require
   [reagent.core :as r]
   [reagent.dom.client :as rdom]
   [reagent-flow.core
    :refer [add-edge apply-edge-changes apply-node-changes
            background reagent-flow reagent-flow-provider
            use-on-viewport-change
            get-node-by-id]]))
(defonce node-id
  (r/atom 0))

(defonce nodes
  (r/atom [{:id          "explanation"
            :selectable  false
            :connectable false
            :draggable   false
            :position    {:x 0 :y 0}
            :data        {:label "Drag some nodes in from the panel above"}}]))

(defonce edges
  (r/atom []))

Here you can note the use of on-viewport-change which is not part of the original ReactFlow component. In ReactFlow, this is available as a hook, but we try to avoid hooks for simplicity. The handler receives a map with x, y & zoom values, just as the hook equivalent.

(defn- main []
  (let [flow      (atom nil)
        provider  (atom nil)
        viewport  (r/atom {:x 0 :y 0 :zoom 1})
        data-type "application/reagentflow"]
    (letfn [(handle-drag [event]
              (let [data-transfer (-> event .-dataTransfer)]
                (.setData data-transfer data-type "default")
                (set! (-> data-transfer .-effectAllowed) "move")))
            (handle-node-changes [changes]
              (reset! nodes (apply-node-changes changes @nodes)))
            (handle-edge-changes [changes]
              (reset! edges (apply-edge-changes changes @edges)))
            (handle-connect [connection]
              (reset! edges (add-edge connection @edges)))
            (handle-drop [event]
              (.preventDefault event)
              (when-let [node-type (.getData (-> event .-dataTransfer) data-type)]
                (let [{:keys [screen-to-flow-position]} @provider
                      flow-el           (-> flow .-state .-firstChild) 
                      rect              (.getBoundingClientRect flow-el)
                      position          (screen-to-flow-position {:x (.-clientX event)
                                                                  :y (.-clientY event)})]
                  (swap! node-id inc)
                  (swap! nodes conj {:id       (str "node-" @node-id)
                                     :type     node-type
                                     :position position
                                     :data     {:label (str "Node #" @node-id)}}))))
            (handle-drag-over [event]
              (.preventDefault event)
              (set! (-> event .-dataTransfer .-dropEffect) "move"))]
      (fn []
        [:<>
         [:menu.node-palette
          [:div.node {:draggable     true
                      :on-drag-start handle-drag} "Node"]]
         [reagent-flow {:ref                  #(reset! flow %)
                        :id                   :drop-it-like-its-hot
                        :nodes                @nodes
                        :edges                @edges
                        :fit-view             true
                        :on-init              #(reset! provider %)
                        :on-nodes-change      handle-node-changes
                        :on-edges-change      handle-edge-changes
                        :on-connect           handle-connect
                        :on-drop              handle-drop
                        :on-drag-over         handle-drag-over
                        :on-viewport-change   #(reset! viewport %)
                        :connection-line-type :smoothstep
                        :default-edge-options {:type :smoothstep}}
          [background {:style {:background-color "#ffffff"}}]]]))))
<<init>>

Stress

This example stress-tests react-flow rendering in combination with reagent state-handling.
(ns stress.core
  (:require
   [clojure.set :as set :refer [union]]
   [reagent.core :as r]
   [reagent.dom.client :as rdom]
   [reagent-flow.core
    :refer [add-edge apply-edge-changes apply-node-changes
            background handle reagent-flow
            get-connections-by-node-id get-node-by-id]]))

We create 100 nodes in total; all sorted into a grid with connections running between every node. There’s one sum-node that adds together the value of each of the connected nodes. Only connections that affect the sum are animated.

(def num-nodes 100)
(def rows (/ num-nodes 10))
(def cols (/ num-nodes rows))

(defonce sum-node-value (r/atom 0))

Each node and edge can be uniquely identified and as such also modified. Try making a connection from Node #99 to the Sum node and see the value of the nodes propagate through the grid.

(defonce nodes
  (r/atom (into (->> (range 1 (inc num-nodes))
                     (mapv (fn [idx]
                             (let [x (* 200 (mod (dec idx) cols))
                                   y (* 200 (quot (dec idx) cols))]
                               {:id       (str "node-" idx)
                                :type     (if (= idx 1) :input :default)
                                :position {:x x :y y}
                                :data     {:label (str "Node #" idx)
                                           :value idx}}))))
                [{:id        :sum-node
                  :type      :sum-node
                  :deletable false
                  :position  {:x (* 200 (dec cols)) :y (* 200 rows)}}])))

(defonce edges
  (r/atom (->> (range 1 (inc num-nodes))
               (mapv (fn [idx]
                       (merge 
                        {:id (str "edge-" idx)}
                        (when (> idx 1)
                          {:source (str "node-" (dec idx))})
                        (when (< idx (inc num-nodes))
                          {:target (str "node-" idx)})))))))

We use a few helper-functions to achieve this which you can see here.

(defn- follow-source [edge connections]
  (if-let [source (get edge :source)]
    (let [sources (some #(when (= (name source) (name (:target %))) %) connections)]
      (conj (follow-source sources connections) edge))
    [edge]))

(defn- animate [connected connections]
  (map (fn [connection]
         (let [connection (dissoc connection :animated)]
           (if-let [edge (some #(when (= (:target %) (:target connection)) %) connected)]
             (assoc edge :animated true)
             connection)))
       connections))

(defn- sum [connected]
  (transduce (comp (map :source)
                (map (partial get-node-by-id @nodes))
                (map (comp :value :data)))
             + 0 connected))

(defn- sum-node-edge [connection connections]
  (or (when (= (:target connection) "sum-node") connection)
      (first (get-connections-by-node-id connections :sum-node :target))))

(defn- find-connected [connection connections]
  (sequence
   (comp (mapcat #(follow-source % connections))
      (filter some?))
   [(sum-node-edge connection connections)]))

Our sum-node is really simple, but it needs to be a function for the atom to be de-referenced upon change.

(defn- sum-node []
  [:<>
   [:pre (str "Sum: " @sum-node-value)]
   [handle {:id       :sum-handle
            :type     :target
            :position :top}]])

(defonce node-types
  {:sum-node sum-node})

And here we put the stress example together. Note that we use set-center upon initialization and how that is treated as a regular ClojureScript function.

(defn- main []
  (letfn [(handle-node-changes [delta]
            (reset! nodes (apply-node-changes delta @nodes)))
          (handle-edge-changes [delta]
            (let [connections (apply-edge-changes delta @edges)]
              (condp = (-> delta first :type (keyword))
                :remove (let [connected   (find-connected delta connections)
                              connections (animate connected connections)]
                          (reset! sum-node-value (sum connected))
                          (reset! edges connections))
                (reset! edges connections))))
          (handle-connect [delta]
            (let [delta       (assoc delta :id (str "edge-" (+ 2 (count @edges))))
                  connections (add-edge delta @edges)
                  connected   (find-connected delta connections)
                  connections (animate connected connections)]
              (reset! sum-node-value (sum connected))
              (reset! edges connections)))
          (handle-init [{:keys [set-center] :as provider}]
            (let [x (* 200 (dec cols))
                  y (* 200 (dec rows))]
              (set-center x y {:zoom 0.85})))]
    (fn []
      [reagent-flow {:id                   :stress
                     :nodes                @nodes
                     :edges                @edges
                     :node-types           node-types
                     :on-nodes-change      handle-node-changes
                     :on-edges-change      handle-edge-changes
                     :on-connect           handle-connect
                     :on-init              handle-init
                     :connection-line-type :smoothstep
                     :default-edge-options {:type :smoothstep}}
       [background {:style {:background-color "#ffffff"}}]])))
<<init>>

A bit of Structure

This example shows how you can combine reagent-flow with re-frame and other third party libraries. The library we’re using here is Leva.
(ns visual-programming.core
  (:require
   [leva.core :as leva]
   [re-frame.core :as re-frame]
   [reagent.core :as r]
   [reagent.dom.client :as rdom]
   [reagent-flow.core
    :refer [add-edge apply-edge-changes apply-node-changes
            background reagent-flow reagent-flow-provider
            handle use-on-viewport-change get-node-by-id]]))

(defn number-node []
  (let [n 1]
    [:div {:style {:width :20em}}
     [leva/SubPanel {:fill      true
                     :flat      true
                     :title-bar false}
      [leva/Controls
       {:schema {:number n}}]]
     [handle {:type     :source
              :position :bottom
              :id       n}]]))

(defn vector-node []
  [:div {:style {:width :20em}}
   [leva/SubPanel {:fill      true
                   :flat      true
                   :title-bar false}
    [leva/Controls
     {:schema {:vector [0 2 6]}}]] 
   [handle {:type     :target
            :position :top
            :id       :x
            :style    {:left 135}}]
   [handle {:type     :target
            :position :top
            :id       :y
            :style    {:left 190}}]
   [handle {:type     :target
            :position :top
            :id       :z
            :style    {:left 248}}]])

(defonce node-types
  {:vector-node vector-node
   :number-node number-node})

(def default-db
  {:nodes [{:id       :n1
            :type     :number-node
            :position {:x 120 :y 0}}
           {:id       :v1
            :type     :vector-node
            :position {:x 0 :y 100}}]
   :edges []})

(re-frame/reg-event-db :init (fn [_ _] default-db))
(re-frame/reg-event-db :nodes assoc)
(re-frame/reg-event-db :edges assoc)
(re-frame/reg-sub :nodes get-in)
(re-frame/reg-sub :edges get-in)

(defn- main []
  (let [nodes (re-frame/subscribe [:nodes])
        edges (re-frame/subscribe [:edges])]
    (re-frame/dispatch-sync [:init])
    (letfn [(handle-node-changes [changes]
              (re-frame/dispatch [:nodes (apply-node-changes changes @nodes)]))
            (handle-edge-changes [changes]
              (re-frame/dispatch [:edges (apply-edge-changes changes @edges)]))
            (handle-connect [connection]
              (re-frame/dispatch [:edges (add-edge connection @edges)]))]
      (fn []
        [reagent-flow {:nodes                @nodes
                       :edges                @edges
                       :node-types           node-types
                       :fit-view             true
                       :on-nodes-change      handle-node-changes
                       :on-edges-change      handle-edge-changes
                       :on-connect           handle-connect
                       :connection-line-type :smoothstep
                       :default-edge-options {:animated true
                                              :type     :smoothstep}}
         [background]]))))

<<init>>

Manifests

Deps.edn

{:deps  {org.clojure/clojure                             {:mvn/version "1.11.3"}
         org.clojure/clojurescript                       {:mvn/version "1.11.132"}
         com.google.javascript/closure-compiler-unshaded {:mvn/version "v20230411"}
         reagent/reagent                                 {:mvn/version "1.2.0"}
         re-frame/re-frame                               {:mvn/version "1.4.3"}
         ;; io.github.mentat-collective/leva.cljs        {:git/sha "bb24493c8b4a0fcd862d69b4960fa297561fa5bb"}
         net.clojars.simtech/reagent-flow                {:local/root "../"}}
 :paths ["src"]
 :aliases
 {:watch {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.14"}
                       binaryage/devtools   {:mvn/version "1.0.7"}}
          :main-opts  ["-m" "shadow.cljs.devtools.cli" "watch" "examples"]}
  :build {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.14"}
                       binaryage/devtools   {:mvn/version "1.0.7"}}
          :main-opts  ["-m" "shadow.cljs.devtools.cli" "compile" "examples"]}}}

Shadow-cljs.edn

{:deps  true
 :nrepl {:port 9001}
 :builds
 {:examples
  {:modules
   {:examples {:entries [custom-nodes.core
                         drop-it-like-its-hot.core
                         stress.core
                         ;; visual-programming.core
                         ]}}
   :target     :browser
   :asset-path "js"
   :output-dir "../../docs/js/"
   :devtools   {:preloads  [devtools.preload]
                :http-root "../../docs"
                :http-port 3000}}}}

Implementation

We use the same version-scheme as ReactFlow. You’re currently viewing version:
12.2.0

Here you’ll find all the names of the classes, functions, hooks, and types of this version of ReactFlow listed.

Classes

  • Background
  • BaseEdge
  • BezierEdge
  • ControlButton
  • Controls
  • EdgeLabelRenderer
  • EdgeText
  • Handle
  • MiniMap
  • NodeResizer
  • NodeResizeControl
  • NodeToolbar
  • Panel
  • Position
  • ReactFlow
  • ReactFlowProvider
  • SimpleBezierEdge
  • SmoothStepEdge
  • StepEdge
  • StraightEdge

Functions

  • boxToRect
  • getBezierPath
  • getBoundsOfRects
  • getConnectedEdes
  • getIncomers
  • getMarkerEnd
  • getOutgoers
  • getRectOfNodes
  • getSimpleBezierPath
  • getSmoothStepPath
  • getStraightPath
  • getTransformForBounds
  • internalsSymbol
  • isEdge
  • isNode
  • rectToBox
  • updateEdge

Hooks

  • useReactFlow
  • useUpdateNodeInternals
  • useNodes
  • useEdges
  • useViewport
  • useKeyPress
  • useStore
  • useStoreApi
  • useOnViewportChange
  • useOnSelectionChange
  • useNodesInitialized
  • useNodesState
  • useEdgesState

Types

  • Position
  • XYPosition
  • XYZPosition
  • Dimensions
  • Rect
  • Box
  • Transform
  • CoordinateExtent
  • Node
  • NodeMouseHandler
  • NodeDragHandler
  • SelectionDragHandler
  • WrapNodeProps
  • NodeProps
  • NodeHandleBounds
  • NodeDimensionUpdate
  • NodeInternals
  • NodeBounds
  • NodeDragItem
  • NodeOrigin
  • ReactFlowJsonObject
  • Instance
  • ReactFlowInstance
  • HandleType
  • StartHandle
  • HandleProps
  • NodeTypes
  • NodeTypesWrapped
  • EdgeTypes
  • EdgeTypesWrapped
  • FitView
  • Project
  • OnNodesChange
  • OnEdgesChange
  • OnNodesDelete
  • OnEdgesDelete
  • OnMove
  • OnMoveStart
  • OnMoveEnd
  • ZoomInOut
  • ZoomTo
  • GetZoom
  • GetViewport
  • SetViewport
  • SetCenter
  • FitBounds
  • OnInit
  • Connection
  • ConnectionMode
  • OnConnect
  • FitViewOptions
  • OnConnectStartParams
  • OnConnectStart
  • OnConnectEnd
  • Viewport
  • KeyCode
  • SnapGrid
  • PanOnScrollMode
  • ViewportHelperFunctionOptions
  • SetCenterOptions
  • FitBoundsOptions
  • UnselectNodesAndEdgesParams
  • OnViewportChange
  • ViewportHelperFunctions
  • ReactFlowStore
  • ReactFlowActions
  • ReactFlowState
  • UpdateNodeInternals
  • OnSelectionChangeParams
  • OnSelectionChangeFunc
  • PanelPosition
  • ProOptions
  • SmoothStepPathOptions
  • BezierPathOptions
  • Edge
  • DefaultEdgeOptions
  • EdgeMouseHandler
  • WrapEdgeProps
  • EdgeProps
  • BaseEdgeProps
  • SmoothStepEdgeProps
  • BezierEdgeProps
  • EdgeTextProps
  • ConnectionLineType
  • ConnectionLineComponentProps
  • ConnectionLineComponent
  • OnEdgeUpdateFunc
  • EdgeMarker
  • EdgeMarkerType
  • MarkerType
  • ReactFlowProps
  • ReactFlowRefType
  • NodeDimensionChange
  • NodePositionChange
  • NodeSelectionChange
  • NodeRemoveChange
  • NodeAddChange
  • NodeResetChange
  • NodeChange
  • EdgeSelectionChange
  • EdgeRemoveChange
  • EdgeAddChange
  • EdgeResetChange
  • EdgeChange

Note that types & hooks can pretty much be ignored, but are still here for completeness sake. If you find the need for them, please tell us about your usecase.

Process lists

Here the lists above are processed to get the ClojureScript equivalent functionality.

(s-join "\n" (-concat classes functions hooks))
(->> classes
     (--map
      (format "(def %s%s (r/adapt-react-class %s))"
              (if (or (equal it "ReactFlow")
                      (equal it "ReactFlowProvider"))
                  "^:private "
                "")
              (react->reagent it)
              it))
     (s-join "\n"))
(->>
 (-concat 
  (->> (sort (-concat functions hooks) 's-less?)
       (--map (format "(def ^{:private %s} %s %s)" (if (-contains? '("useUpdateNodeInternals") it) "false" "true") (react->reagent it) (s-trim it))))
  (--map (format "(def ^{:nodoc true :const true} Flow%s rf/%s)" (s-trim it) (s-trim it))
         types))
 (s-join "\n" ))

Core

With the lists processed, we assemble our core namespace by using these processed lists.

<<preamble>>
(ns reagent-flow.core
  "A ClojureScript library that wraps ReactFlow"
  (:require
   [camel-snake-kebab.core :refer [->kebab-case ->camelCase]]
   [clojure.set :refer [rename-keys]]
   [clojure.string :as str]
   [clojure.walk :refer [postwalk]]
   [cljs.core :refer [IDeref IEditableCollection]]
   [medley.core :refer [map-keys map-vals]]
   [reagent.core :as r]
   ["@xyflow/react$default" :as ReactFlow]
   ["@xyflow/react" :as rf
    :refer [addEdge
            applyEdgeChanges
            applyNodeChanges
            <<refer()>>
            ]]))

<<adapted-classes()>>

<<defs()>>

To create our main entry-point functions, we need a few private helper-functions:

(def -->kebab-case (memoize ->kebab-case))

(def -->camelCase (memoize ->camelCase))

(defn- ->params
  "Normalize arguments to always have the form [props children] like
   hiccup elements."
  [args]
  (cond-> args
    (-> args first map? not) (conj nil)))

(defn- change-keys
  "Walks a map and replaces all keys by applying function to the keys."
  [m f]
  (let [f (fn [[k v]] (if (or (string? k) (keyword? k)) [(f k) v] [k v]))]
    (postwalk (fn [x] (if (map? x) (into {} (map f x)) x)) m)))

(defn- flowjs->clj [o]
  "Convert a JavaScript object to a Clojure map with kebab-cased keys."
  (let [obj (js->clj o :keywordize-keys true)]
    (if (map? obj)
      (change-keys (dissoc obj "") -->kebab-case)
      (if (vector? obj)
        (map flowjs->clj obj)
        obj))))

(defn- clj->flowjs
  "Convert Clojure map into a JavaScript object with camelCased keys."
  [o]
  (->> (change-keys o -->camelCase)
       (clj->js)))

(defn- apply-changes [f delta src]
  (-> (f (clj->flowjs delta) (clj->flowjs src))
      (flowjs->clj)))

(defn- react-flowify [types]
  (clj->js ((partial map-vals r/reactify-component) types)))

We need reagent-flow to be a functional react-component in order to use hooks. We therefor have a private function by the name of reagent-flow* which does most of the job and is later wrapped by a public function with the name reagent-flow. We only rely on the use-on-viewport-change-hook as we can manage all the other state directly via atoms.

(defn- reagent-flow*
  [[on-viewport-change on-viewport-start on-viewport-end & args]]
  (let [[params & children] (->params args)
        node-types          (when-let [types (:node-types params)] (react-flowify types))
        edge-types          (when-let [types (:edge-types params)] (react-flowify types))
        on-init             (when-let [init (:on-init params)]
                              (fn [provider]
                                (let [provider                     (flowjs->clj provider)
                                      {:keys [set-center screen-to-flow-position]} provider]
                                  (init (assoc provider
                                               :set-center (fn [x y & options] (set-center x y (clj->js (first options))))
                                               :screen-to-flow-position #(screen-to-flow-position (clj->js %)))))))
        on-nodes-change     (when-let [node-change (:on-nodes-change params)]
                              (fn [delta] (node-change (flowjs->clj delta))))
        on-edges-change     (when-let [edge-change (:on-edges-change params)]
                              (fn [delta] (edge-change (flowjs->clj delta))))
        on-connect          (when-let [connect (:on-connect params)]
                              (fn [delta] (connect (flowjs->clj delta))))
        on-connect-start    (when-let [connect-start (:on-connect-start params)]
                              (fn [event params] (connect-start event (flowjs->clj params))))
        params              (dissoc params :node-types :edge-types)]
    (fn [[on-viewport-change on-viewport-start on-viewport-end & args]]
      (let [[params & children] (->params args)
            params              (merge (dissoc params :node-types :edge-types :edges)
                                       (map-vals clj->js params)
                                       {:edges (clj->flowjs (:edges params))}
                                       (when node-types {:node-types node-types})
                                       (when edge-types {:edge-types edge-types})
                                       (when on-init {:on-init on-init})
                                       (when on-nodes-change {:on-nodes-change on-nodes-change})
                                       (when on-edges-change {:on-edges-change on-edges-change})
                                       (when on-connect {:on-connect on-connect})
                                       (when on-connect-start {:on-connect-start on-connect-start}))]
        (when (or (some? on-viewport-change)
                  (some? on-viewport-start)
                  (some? on-viewport-end))
          (use-on-viewport-change
           (clj->js
            (merge {}
                   (when (some? on-viewport-change)
                     {:onChange #(on-viewport-change (flowjs->clj %))})
                   (when (some? on-viewport-start)
                     {:onStart #(on-viewport-start (flowjs->clj %))})
                   (when (some? on-viewport-end)
                     {:onEnd #(on-viewport-end (flowjs->clj %))})))))
        (into [react-flow params] children)))))

These are the only exposed functions of reagent-flow that differs from react-flow in other ways than just naming. Mostly just interop measures, so you won’t have to convert data-structures all over your client-code.

(defn apply-node-changes
  "Returns a vector of nodes with `changes` applied to the `source`."
  [changes source]
  (vec (apply-changes applyNodeChanges changes source)))

(defn apply-edge-changes
  "Returns a vector of edges with `changes` applied to the `source`."
  [changes source]
  (vec (apply-changes applyEdgeChanges changes source)))

(defn add-edge
  "Returns a vector of edges with `edge` added to the `source`."
  [edge source]
  (vec (apply-changes addEdge edge source)))

(defn get-node-by-id
  "Returns a map of the node with `id` from `nodes`. 
   The returned map is supplemented with the keyword `index`.

   Returns `nil` if the node is not found."
  [nodes id]
  (when (some? id)
   (letfn [(item-with-id [idx itm]
             (when (= (name id) (name (:id itm)))
               (assoc itm :index idx)))]
     (->> nodes
          (keep-indexed item-with-id)
          (first)))))

(defn get-connections-by-node-id
  "Returns a vector of connections where the node with `id` is either
   the source or the target.

   Returns an empty vector if no connections are found."
  [connections id & which]
  (let [which (or which [:source :target])]
    (when (some? id)
      (letfn [(items-with-id [idx itm]
                (when (some #(= (name id) (name %)) (map #(get itm %) which))
                  (assoc itm :index idx)))]
        (->> connections
             (keep-indexed items-with-id)
             (into []))))))

(defn reagent-flow
  "This is the main component of `reagent-flow`. It differs from
  `ReactFlow` in a few ways.

   - You pass regular Clojure data-structures to all paramaters, so
     vectors instead of arrays, maps instead of objects and so on.
   - Viewport events are baked in, so you use the events
     `on-viewport-(change|start|end)` to listen for changes in the
     Viewport.
   - reagent-flow-provider is also used, so if you need to have
     multiple flows on the same page, just be sure to give each of them a
     unique `id`.

   Note!
   Node-types & edge-types are called directly from within ReactFlow,
   so the parameters returned are in their JavaScript-form. A nice
   pattern, is to only rely on the `id` from the parameters and do
   lookups in your state manually.
   Ex.
   (defn- custom-node [{:keys [id]}]
     (let [node (flow/get-node-by-id @nodes id)
           data (:data node)]
       [:p (:label data)]))"
  [params & children]
  (let [on-viewport-change (:on-viewport-change params)
        on-viewport-start  (:on-viewport-start params)
        on-viewport-end    (:on-viewport-end params)
        params             (dissoc params :on-viewport-change :on-viewport-start :on-viewport-end)]
    [reagent-flow-provider
     [:f> reagent-flow*
      (into [on-viewport-change
             on-viewport-start
             on-viewport-end
             params] children)]]))

Tests

(ns reagent-flow.core-test
  (:require
   [cljs.test :refer-macros [deftest testing is]]
   [clojure.test.check.clojure-test :refer [defspec]]
   [reagent-flow.core :refer [get-node-by-id get-connections-by-node-id]]
   [clojure.spec.alpha :as s]
   [clojure.test.check.properties :as prop]
   [clojure.test.check.generators :as gen]))

;; Map with string or keyword keys and scalar values
(s/def ::key (s/and (s/or :str string? :kw keyword?) (complement empty?)))
(s/def ::value (s/or :string string? :number number? :bool boolean?))
(s/def ::map (s/map-of ::key ::value))
(s/def ::nested-map
  (s/map-of ::key (s/or :map ::map :vec (s/coll-of ::value) :value ::value)))

(defspec test-change-keys 100
  (prop/for-all [sample-map (s/gen ::nested-map)]
                (let [transformed (#'reagent-flow.core/change-keys sample-map keyword)]
                  (is (every? (comp keyword? first) transformed)))))

;; TODO Could use a generative test for `test-flowjs->clj` as well
(deftest test-flowjs->clj
  (testing "Ensure flowjs->clj handles JS objects correctly"
    (let [js-obj {:aKey "value" :nestedObj {:anotherKey 42} :aList [1 2 3]}]
      (is (= (#'reagent-flow.core/flowjs->clj js-obj)
             {:a-key "value" :nested-obj {:another-key 42} :a-list [1 2 3]})))))

(deftest getting-node-by-id
  (let [nodes [{:id :node1} {:id :node2}]] 
    (testing "Retrieving a node by it's id, should return the node enriched with it's index"
      (is (= {:id :node2 :index 1} (get-node-by-id nodes :node2))))
    (testing "Should return `nil` when node is missing"
      (is (nil? (get-node-by-id nodes :missing))))))

(deftest getting-connections-by-node-id
  (let [connections [{:id :conn1 :source :node1 :target :node2}
                     {:id :conn2 :source :node1 :target :node3}
                     {:id :conn3 :source :node3 :target :node2}]]
    (testing "Getting source connections by node id"
      (is (= [{:id :conn1 :source :node1 :target :node2 :index 0}
              {:id :conn2 :source :node1 :target :node3 :index 1}]
             (get-connections-by-node-id connections :node1 :source))))
    (testing "Getting target connections by node id"
      (is (= [{:id :conn1 :source :node1 :target :node2 :index 0}
              {:id :conn3 :source :node3 :target :node2 :index 2}]
             (get-connections-by-node-id connections :node2 :target))))
    ;; Note that the third/forth parameter is not needed when retrieving both
    (testing "Getting both source and target connections by node id"
      (is (= [{:id :conn2 :source :node1 :target :node3 :index 1}
              {:id :conn3 :source :node3 :target :node2 :index 2}]
             (get-connections-by-node-id connections :node3))))))

Manifests

Deps.edn

{:deps  {org.clojure/clojure                 {:mvn/version "1.11.3"}
         org.clojure/clojurescript           {:mvn/version "1.11.132"}
         camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
         dev.weavejester/medley              {:mvn/version "1.8.1"}
         reagent/reagent                     {:mvn/version "1.2.0"}}
 :paths ["src"]
 :aliases
 {:build   {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.14"}}
            :main-opts  ["-m" "shadow.cljs.devtools.cli" "release" "reagent-flow"]}
  :test    {:extra-deps  {olical/cljs-test-runner {:mvn/version "3.8.1"}
                          org.clojure/test.check {:mvn/version "1.1.1"}}
            :extra-paths ["test" "cljs-test-runner-out/gen"]
            :main-opts   ["-m" "cljs-test-runner.main" "-d" "test"]}
  :package {:deps       {io.github.clojure/tools.build {:git/url "https://github.com/clojure/tools.build"
                                                        :git/sha "143611fcf965919f1d9c18a10eeeed319305e034"}
                         slipset/deps-deploy           {:mvn/version "0.2.2"}}
            :ns-default package}}}

Shadow-cljs.edn

{:deps  true
 :nrepl {:port 9000}
 :builds
 {:reagent-flow
  {:modules {:reagent-flow {:entries [reagent-flow.core]}}
   :target     :browser
   :asset-path "js"
   :output-dir "target/classes/public/js"}}}
{:npm-deps {"@xyflow/react" "<<version>>"
            "react"         "^18.3.1"
	    "react-dom"     "^18.3.1"}}

Package.json

{
    "name": "reagent-flow",
    "version": "<<version>>",
    "private": true,
    "license": "MIT",
    "dependencies": {
	"@xyflow/react":          "<<version>>"
    },
    "devDependencies": {
	"react":              "^18.3.1",
	"react-dom":          "^18.3.1"
    }
}

Pom-template.xml

The final pom.xml file is actually created using tools.build, but it uses the below structure as it’s base.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <packaging>jar</packaging>
  <groupId>net.clojars.simtech</groupId>
  <artifactId>reagent-flow</artifactId>
  <name>reagent-flow</name>
  <description>A ClojureScript library that wraps ReactFlow</description>
  <url>https://github.com/dnv-opensource/reagent-flow</url>
  <developers>
    <developer>
      <name>Henrik Kjerringvåg</name>
    </developer>
  </developers>
  <licenses>
    <license>
      <name>MIT</name>
      <url>https://mit-license.org/</url>
    </license>
  </licenses>
</project>
(ns package
  (:require
   [clojure.tools.build.api :as b]))

(def version "<<version>>")
(def lib 'net.clojars.simtech/reagent-flow)
(def class-dir "target/classes")
(def basis (b/create-basis {:project "deps.edn"}))
(def jar-file (format "target/%s-%s.jar" (name lib) version))

(defn jar [_]
  (b/write-pom {:src-pom   "pom-template.xml" 
                :version   version
                :class-dir class-dir
                :lib       lib
                :basis     basis
                :src-dirs  ["src"]
                :scm       {:tag               (str "v" version)
                            :connection        "scm:git:git://github.com/dnv-opensource/reagent-flow.git"     
                            :developConnection "scm:git:ssh://[email protected]/dnv-opensource/reagent-flow.git" 
                            :url               "https://github.com/dnv-opensource/reagent-flow"}})
  (b/copy-dir {:src-dirs   ["src"] 
               :target-dir class-dir})
  (b/jar {:class-dir class-dir
          :jar-file  jar-file})
  (println (str jar-file " created!"))
  {:class-dir class-dir
   :jar-file  jar-file})

(defn deploy [_]
  (let [{:keys [jar-file]} (jar nil)]
    ((requiring-resolve 'deps-deploy.deps-deploy/deploy)
     {:installer      :remote
      :sign-releases? false
      :artifact       jar-file
      :pom-file       (b/pom-path {:lib       lib
                                   :class-dir class-dir})})
    (println (format "Deployed %s to Clojars" jar-file))))

Contributing

The repository for this library can be found on github.

As mentioned, reagent-flow is just a wrapper, so there’s not much logic here. If you discover any issues, those are likely to stem from ReactFlow and should be reported there. If you are confident that you’ve discovered an issue with this wrapper or have some feedback, feel free to open an issue.

The wrapper is written in a literate style using org-mode; so to contribute code, the easiest path is to use Emacs for the time being. All code, tests and documentation is in index.org, from there it’s about tangling and weaving the document:

  • C-c C-v t will tangle the source-code into files on disk (babel/).
  • M-x org-publish-project & reagent-flow will weave the documentation onto the filesystem (docs/).

After having done this, you should be able to build & run tests locally:

Building

npm i
clojure -M:build

Running tests

clojure -M:test

Running examples locally

First make sure that index.org and Setup.org are both tangled.

(dolist (file '("index.org" "Setup.org"))
  (org-babel-tangle-file file))

Then publish the documentation.

(org-publish-project "reagent-flow")

Then run the shadow watcher in the babel/examples directory

npm i
clj -M:watch

and follow the instructions that appear.

Publishing

Whenever a pull-request is merged into main, a github-action takes over. The action will build & run tests. If the tests pass ✓️, the library will be packed into a jar. To actually publish to Clojars and update github pages with the latest documentation, you’ll have to create a tag.

Auxiliary

This is specific to org-mode & should be the last piece of the document. We load Setup.org & some of the source-blocks therein.