đź‘‹
Today’s brainstorming will be about giving an example of how a user would use Vrac to build forms. Although there are many ways to do it, I am going to use https://github.com/reagent-project/reagent-forms as a reference, as well as ideas from https://www.youtube.com/watch?v=IekPZpfbdaI which, similarly to Vrac, is using a declarative approach. Then I will attempt to describe how it would translate in Vrac by taking advantage of the fact that templates are highly readable data.
I am watching the video and I take notes. At 7:00 in the video, we see
{:title "Patient Intake Form"
:metadata {:form-name "patient-intake-form"}
:model {:height {:path "patient/vital/height"}
:weight {:path "patient/vital/weight"}
:bmi {:path "patient/vital/bmi"}}
:view {:default {:form [[:widget {:type :section
:title "Vitals"}
[:widget {:type :numeric
:title "Height (cm)"
:path :height}]
[:widget {:type :numeric
:title "Weight (kg)"
:path :weight}]
[:widget {:type :numeric
:title "BMI"
:path :bmi}]]]}}}
There are a few things to notice;
1. The model’s properties and the view’s properties are decoupled.
2. The link between the model and the view is a path which identify parts of the model.
3. The approach is data driven, the edn above is data which is fed into a function which will output hiccup with some plumbing on how to update the data from the user’s interaction with the form, and how to update the form from data changes.In Vrac, there are multiple ways to do it, depending on how data driven you want/need to be.
Let’s see the most simple way first, by hardcoding the form’s structure into the Vrac components:
(defc numeric [title data]
(let [form-id (gen-id "form-")] ;; non-reactive binding to a generated unique string
[:div
[:label {:for form-id} title]
[:input {:type :number
:id form-id
:value (:value data)
:on-change (dispatch [:input-change data])}]]))
(defc section [title & children]
[:div
[:h1 title]
(for [child children]
[child])])
(defc patient-intake-form [patient]
(let [{:keys [height weight bmi]} (:vital patient)]
[:form
[section "Vitals"
[numeric "height (cm)" height]
[numeric "Weight (kg)" weight]
[numeric "BMI" bmi]]]))
Here we have the view part of the form. The binding between “which data” and “which view” is done in the same way Clojure does when putting together functions and arguments, with some optional use of let
to keep things tidy.
As a side note, not using let
has no impact on performances.
;; Alternative formulation, no impact on performance.
(defc patient-intake-form [patient]
[:form
[section "Vitals"
[numeric "height (cm)" (-> patient :vital :height)]
[numeric "Weight (kg)" (-> patient :vital :weight)]
[numeric "BMI" (-> patient :vital :bmi)]]])
Now, if we don’t want the component patient-intake-form
to be strongly linked to the specific components section
and numeric
, we can pass them in parameters.
(defc patient-intake-form [patient form-comps]
(let [{:keys [height weight bmi]} (:vital patient)
{:keys [section numeric]} form-comps]
[:form
[section "Vitals"
[numeric "height (cm)" height]
[numeric "Weight (kg)" weight]
[numeric "BMI" bmi]]]))
form-comps
is an hash-map whose values are component ids. For example:
;; "Rainbow form theme"
{:section ::rainbow/section
:numeric ::rainbow/numeric}
The form components can also be accessed in a global way rather than passed in parameters:
(defc patient-intake-form [patient]
(let [{:keys [height weight bmi]} (:vital patient)
{:keys [section numeric]} (:form-comps global)]
[:form
[section "Vitals"
[numeric "height (cm)" height]
[numeric "Weight (kg)" weight]
[numeric "BMI" bmi]]]))
For a better genericity, the form properties could be passed to form components via an hash-map:
(defc patient-intake-form [patient]
(let [{:keys [height weight bmi]} (:vital patient)
{:keys [section numeric]} (:form-comps global)]
[:form
[section (val {:title "Vitals"})
[numeric (val {:title "Height (cm)"
:data height})]
[numeric (val {:title "Weight (kg)"
:data weight})]
[numeric (val {:title "BMI"
:data bmi})]]]))
Now, If we wanted to really go data-driven and not hard-code the structure into components, we could do it via a recursive component. That’s a little bit complicated to show here, but it seems doable.
The bridge between a form description as data and a component composition may look like that:
(defc form-builder-comp [form-desc data]
(let [{:keys [type data-path children]} form-desc
data (get-in data data-path)]
[(-> global :form-comps type)
(val {:data data})
(for [child-form-desc children]
[form-builder-comp child-form-desc data])]))
Side note: to avoid using val
in the template, we can also do like this: [my-comp {} value1 value2 ...]
I did not mention about the event handling yet, but the idea is to provide the event process with the path of the data as seen outside of the template.
Then, the event handler is wrapped inside a function which provides it with the input data via get-in
, and then use the output effect to possibly update the local-db using update-in
.
It may sound familiar, re-frame has a way to do that already. The difference is that in re-frame the path is static and provided by the library’s user. It can only be used for 1 place in the DB, while in Vrac the data paths depends on which data the event is dispatched.
The benefits are:
1. we decouple event handler from data locations, so that there are possibly less use of get-in
and update-in
in the event handler.
2. we are able to reuse the event handlers for similar processing in different locations in the db.
In Re-frame, the same is achieved by the component by passing in the event what the documentation refers to as "identity" information. In Vrac the determination of the identity is implicitly done by the system in the shape of a path in the db. I means that it is no longer the component's job to do it, and not even the event handler.
It looks like having canonical paths is something important after all.
The cool thing is that the user can do things like (dispatch [:update my-data f arg1 arg2])
.
The data model which is used inside the template is different from the data in the local db. Explanations.
In the template, we are in the semantic world. The data has the shape of a general directed graph (possibly with some cycles) of data entities (e.g. person, blog, article, etc …). In the general case, you can get from one entity to another one by calling a function. Such function can have one or multiple parameters, it can also be a keyword acting as a hash-map look up (because keywords are functions). The result of those functions can be other entities which are in the local db, or it could be a computed value. From the component’s point of view, both will be used in the same way, as a base for further navigation in the graph or to define new computed values.
Outside of the template, we are in the Clojure world. The language’s immutable data structures are trees, not graphs. Between the 2 worlds, there is a gap, a semantic illusion which is built on some very concrete data structure. In order to apply the data access and operations described in the template onto the local db, we need to provide some translation from the semantic world to the Clojure world.
For instance, (-> user :written-articles (get 0) :authors (get 0) :name)
is navigating from a person to an articles, then back to a person. If the data was really represented like that in a Clojure data structure, it would mean that some persons only exist thanks to the articles they wrote - but that’s not how we model data in real life !
Because they are in 2 different worlds, keywords which are used as function do not function the same as in Clojure. Any content of a Vrac template is data representing some user-helping semantic for doing front end. It means that it’s all subject to interpretation, and it would not be anything without an interpretation (because that’s not even Clojure code).
The template function :written-articles
is mapped to a user-provided Clojure function which is doing a look up of any article which have an author that matches the provided user.
It could looks like that:
;; This time, this is really some Clojure code.
(defn person-id->written-articles [db person-id]
(into #{}
(filter (fn [article]
(contains? (:authors article) person-id)))
(:articles db)))