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 originalcamelCasing
. 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 theid
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.
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>>
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>>
(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>>
(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>>
{: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"]}}}
{: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}}}}
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.
- Background
- BaseEdge
- BezierEdge
- ControlButton
- Controls
- EdgeLabelRenderer
- EdgeText
- Handle
- MiniMap
- NodeResizer
- NodeResizeControl
- NodeToolbar
- Panel
- Position
- ReactFlow
- ReactFlowProvider
- SimpleBezierEdge
- SmoothStepEdge
- StepEdge
- StraightEdge
- boxToRect
- getBezierPath
- getBoundsOfRects
- getConnectedEdes
- getIncomers
- getMarkerEnd
- getOutgoers
- getRectOfNodes
- getSimpleBezierPath
- getSmoothStepPath
- getStraightPath
- getTransformForBounds
- internalsSymbol
- isEdge
- isNode
- rectToBox
- updateEdge
- useReactFlow
- useUpdateNodeInternals
- useNodes
- useEdges
- useViewport
- useKeyPress
- useStore
- useStoreApi
- useOnViewportChange
- useOnSelectionChange
- useNodesInitialized
- useNodesState
- useEdgesState
- 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.
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" ))
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)]]))
(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))))))
{: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}}}
{: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"}}
{
"name": "reagent-flow",
"version": "<<version>>",
"private": true,
"license": "MIT",
"dependencies": {
"@xyflow/react": "<<version>>"
},
"devDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}
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))))
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:
npm i
clojure -M:build
clojure -M:test
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.
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.
This is specific to org-mode & should be the last piece of the document. We load Setup.org & some of the source-blocks therein.