Building a library VIII - Extending rendering with functional zippers
Data is really cool to work with, and with the right tools at hand, you can make magic happen. One such tool, which is absolutely fantastic in the right circumstances, are functional zippers.
In short, a functional zipper is a way to navigate through one or several data structures, all the while making changes to them, and then finally realizing the changes at the end. It allows you to take a data structure, make changes to them as you see fit, and then return a completely new data structure based on the old one.
We’ll take a deep dive into a rendering implementation, which will allows us to expand the rendering functionality for the form as a whole, while leaving the rendering of the fields intact as it’s a boundary we wish to keep.
Expanding our rendering
So, we have our general rendering for table, list and paragraphs from part VI, but would like something a bit more flexible.
The really nice way was if we could feed the form how we wanted it to be rendered, telling it where to place the fields, the errors for the fields, etc. If it can be done declaratively, without us having to go into tedious implementations for supporting injections, all the better.
Since we already use hiccup thanks to reagent, we can use that as a basis. Consider the code below.
[:div :$key
:$field
[:span :$errors]]
It’s easy enough to follow. We have a div
with the $key
, $field
and $errors
declared. $errors
is inside span
, because that is what the insanity of the
design from the designers demands and there is nothing you can do about it.
This would work for dictacting the rendering of all the related elements for each individual field. Add in some control over the container of all the fields, and you have something that’s fairly workable.
Expanding to the entire form layout
It falls a bit short if you need even more control of the form layout. Each field will look the same, and sometimes you come across situations where you need different visual implementations for different fields. We can expand the idea to the following.
[:table
[:tr :$account.key
[:td
{:colspan 2}
:$account.field
[:div :$account.errors]]]
[:tr
[:td :$percent.label]
[:td
:$percent.field
[:div :$percent.help]
[:div :$percent.errors]]]]
Given two fields, account
and percent
we put them inside a table, but give
them a slightly different layout. We follow the same pattern as established
above, but can now go down to the individual level for each field.
Reasoning
By using reagent’s use of hiccup vectors, we have expanded the syntax to include our own fields. Two cases have emerged, one a template, the other a wholesome affair where all of the visual layout is spelled out in the hiccup format.
We put a $
marker in front of our expanded vocabularity for two reasons. One,
it makes it easier to implement and two, it makes it easier to visually
identify.
Zipper details
Note |
Whenever you see a zip namespace in the code below, it’s been imported from
clojure.zip .
|
First, we need to setup a zipper, that allows us to navigate a hiccup vector.
There are some default implementations of zippers in clojure.zip
, but this is
the one I have used for quite some time to navigate clojure’s normal data
structures as a sort of general purpose zipper.
(defmulti branch? type)
(defmethod branch? :default [_] false)
(defmethod branch? cljs.core/PersistentVector [v] true)
(defmethod branch? cljs.core/PersistentArrayMap [m] true)
(defmethod branch? cljs.core/List [l] true)
(defmethod branch? cljs.core/IndexedSeq [s] true)
(defmethod branch? cljs.core/LazySeq [s] true)
(defmethod branch? cljs.core/Cons [s] true)
(defmulti seq-children type)
(defmethod seq-children cljs.core/PersistentVector [v] v)
(defmethod seq-children cljs.core/PersistentArrayMap [m] (mapv identity m))
(defmethod seq-children cljs.core/List [l] l)
(defmethod seq-children cljs.core/IndexedSeq [s] s)
(defmethod seq-children cljs.core/LazySeq [s] s)
(defmethod seq-children cljs.core/Cons [s] s)
(defmulti make-node (fn [node children] (type node)))
(defmethod make-node cljs.core/PersistentVector [v children] (vec children))
(defmethod make-node cljs.core/PersistentArrayMap [m children] (into {} children))
(defmethod make-node cljs.core/List [_ children] children)
(defmethod make-node cljs.core/IndexedSeq [node children] (apply list children))
(defmethod make-node cljs.core/LazySeq [node children] (apply list children))
(defmethod make-node cljs.core/Cons [node children] (apply list children))
(prefer-method make-node cljs.core/List cljs.core/IndexedSeq)
(prefer-method make-node cljs.core/List cljs.core/LazySeq)
(prefer-method branch? cljs.core/List cljs.core/IndexedSeq)
(prefer-method branch? cljs.core/List cljs.core/LazySeq)
(prefer-method seq-children cljs.core/List cljs.core/IndexedSeq)
(prefer-method seq-children cljs.core/List cljs.core/LazySeq)
(defn zipper [node]
(zip/zipper branch? seq-children make-node node))
Next we require the code that navigates the zipper.
(defn unwrapper [loc markers]
(let [rest-of-location (zip/rights loc)]
(-> loc
(zip/up)
(zip/replace rest-of-location))))
(defn marker [node]
(if (and (keyword? node)
(str/starts-with? (str node) ":$"))
node))
(defn wire [markers loc]
(let [node (zip/node loc)
mk (marker node)]
(if (contains? markers mk)
(let [value (get markers mk)]
(if (fn? value)
(value loc markers)
(zip/replace loc value)))
loc)))
(defn wiring [frame markers]
(try
(loop [loc (zipper frame)]
(let [next-loc (zip/next loc)]
(if (zip/end? next-loc)
(zip/root loc)
(recur (wire markers next-loc)))))
(catch js/Exception e
(.log js/console e))))
What the code does is really simple. We have a frame
and some markers
. The
frame
is our expanded hiccup syntax, while the markers
is what we introduced
before with our $marker
(such as :$field
). For each $marker
, a
corresponding key (e.g. :field
) exists in markers
and is used to replace
our $marker
in the frame
.
In short, the wiring
function goes through the entire data structure in frame
by using zip/next
. If it’s the end of the zipper, we realize our changes to
the zipper (i.e., we return the new data structure) with zip/root
. If not we
move on to the next location in the frame
. For each step forward we use the
wire
function.
The wire
function takes our markers
and the pointer to the current location
in our frame
. We realize the current location into a node with zip/node
and
then check if it’s a marker by using the marker function
. If the $marker
is
in markers
we replace the entire $marker
with the value in markers
. Unless
it’s a function, in which case we run the function with the current location
(i.e, we are giving the function the zipped frame with all the changes to it, to
do with as the function wishes) and the markers
.
There is also a function called unwrapper
, which takes the the frame
and
removes that entire level of hiccup syntax. In short… if we had
[:div "Hello World!"]
and ran unwrapper on that, we would be left with
"Hello world!"
.
Rendering setup
We now need to expand our common rendering functions to include our new advanced
capabilities. We grab the :wiring
key from our field for the advanced
rendering and then run it through the wiring
function with the wiring
as our
frame
and the markers
generated once in our Form-2 reagent component.
(defn render-wiring [{:keys [wiring] :as field} form]
(let [rendered (wiring/wiring [:$wrapper
wiring]
{:$wrapper wiring/unwrapper
:$key {:key (str "ez-wire-form-" (:id field))}
:$label (render-label field form)
:$field (render-field field form)
:$errors (render-error-element field form)})]
(fn [_ _]
(into [:<>] rendered))))
(defn assoc-wiring [{:keys [name] :as field} params]
(let [params-wiring (:wiring params)]
(assoc field :wiring
(cond (contains? params-wiring name)
(get params-wiring name)
:else
(:wiring field)))))
(defn get-body [row-fn params form]
(let [fields (map (comp #(assoc-wiring % params)
#(get-in form [:fields %])) (:field-ks form))]
(fn [row-fn params form]
[:<>
(for [field fields]
(if (:wiring field)
^{:key (:id field)}
[render-wiring field form]
^{:key (:id field)}
[row-fn field form]))])))
We can now add our form rendering functions. In ez-wire they got named
as-template
and as-wire
.
as-template
(defn row [{:keys [wiring]} _]
wiring)
(defn- adapt-wiring [{:keys [template] :as params} form]
(assoc params :wiring
(reduce (fn [out [_ {:keys [name]}]]
(assoc out name template))
{} (:fields form))))
(defn as-template [{:keys [template/element]
:or {element :div}
:as params}
{:keys [id form-key] :as form} content]
(let [{:keys [template]} params]
(r/create-class
{:display-name "as-template"
:component-will-unmount
(fn [this]
(when (util/select-option :form/automatic-cleanup? form params)
(rf/dispatch [:ez-wire.form/cleanup id])))
:reagent-render
(fn [params form content]
(let [{:keys [style
class]
:or {style {}
class ""}} params]
[element {:key (util/slug "form-template" @form-key)
:style style
:class class}
[common/get-body row (adapt-wiring params form) form]
(if content
[content])]))})))
This piece of code will allow us to create a form like this, with each field
being rendered according to the :template
wiring in the definition of the
form.
(defform my-form-name
{:template [:<>
:$field
:$errors]}
[{:name :percent
:element Input ;; from some popular React component library
:adapter integer-adapter
:validation [:percent-above-0 :percent-below-100]}])
(defn view []
(let [form (my-form-name {} {})]
[as-template {} form]))
as-wire
(defn- ->kw [name k]
(keyword (str "$" (subs (str name) 1) "." (clojure.core/name k))))
(defn wrapped-render [f field form]
[f field form])
(defn- assemble-body [{:keys [wiring]} {:keys [fields] :as form} content]
(let [default-map (if content
{:$content [content]}
{})]
(wiring/wiring wiring (reduce (fn [out [_ {:keys [name] :as field}]]
(merge out
{(->kw name :wrapper) wiring/unwrapper
(->kw name :key) {:key (str "ui-wire-form-wire" (:id field))}
(->kw name :label) (common/render-label field form)
(->kw name :field) [wrapped-render common/render-field field form]
(->kw name :errors) (common/render-error-element field form)}))
default-map fields))))
(defn as-wire [{:keys [wiring/element]
:or {element :div}
:as params}
{:keys [id form-key] :as form} & [content]]
(let [body (assemble-body params form content)]
(r/create-class
{:display-name "as-wire"
:component-will-unmount
(fn [this]
(when (util/select-option :form/automatic-cleanup? form params)
(rf/dispatch [:ez-wire.form/cleanup id])))
:reagent-render
(fn [params form & [content]]
(let [{:keys [style
class]
:or {style {}
class ""}} params]
[element (merge {:key (util/slug "form-wire" @form-key)}
(if (not= element :<>)
{:style style
:class class}))
body]))})))
as-wire
requires a bit more work, as we here have to handle each field
individually in the frame to be sent to the wiring
function. Once that is
done, the code we have setup can still be used as is.
With what we currently have, we can now render our form as such. Notice that we
now add the options with the as-wire
function, instead of defining it as part
of the form as we did above with as-template
.
(defn view []
(let [form (my-form-name {} {})]
[as-wire {{:wiring/element :span
:class "foobar"
:wiring
[:div.wire
:$percent.label
:$percent.field
:$percent.errors]}}
form]))
Repeat
Functional zippers are one of those things that is a bit of a mind bender. In
short, we take a hiccup vector [:div :$field]
, navigate through it, find which
part of the vector is a :$field
and replace it with what we have for that
field.
So… we start at the top of the vector. We navigate next at our current
position, which is the entire vector [:div :$field]
. That moves us into the
vector, and our position is now at :div
. We check to see if is our field, and
it’s not, so we navigate next. Since :div
is not a collection, next will move
us right inside the collection. We now end up at :$field
. This is our field,
and so we replace it with what we have in markers
, which also happens to be
pure data. Our check to see if we are at the end of the vector hits true and
we realize the changes (i.e., we get back our changed hiccup vector). The
changed hiccup vector will now look something like [:div [my-field-fn]]
.
Next up…
We will finish this series with taking a look at branching logic and wizards.