A concrete example

Let’s build a form for a resource that represents a pool of ASNs. The semantics of what it is is not important for our use case, but what is important is how it can be modeled in API and how it can be rendered from UI perspective. We have a similar resource in our product and I decided not to stretch imagination in order to create an example from more widely known domain, but rather get a realistic form example that is more complex than “BMI calculator” or “Address form”.

I’ve built an deployed a sample re-frame application that shows the concepts and code snippets listed below at https://kishanov.github.io/re-frame-spec-forms-example/#/. For each major step in this article there is a corresponding panel in the application that shows a working solution (underscored headers in this articles are permalinks to the related screens in the application).

Payload

An example of valid payload that our form should produce looks like this:

{

"label": "My pool",

"preallocated": true,

"type": "transit",

"tags": [

"private",

"qa-env"

],

"ranges": [

{

"first": 100,

"last": 499

},

{

"first": 1000,

"last": 1100

}

]

}

The schema for this resource imposes the following constraints:

label is a required field that should be a non-empty string which is no longer than 64 characters

is a required field that should be a non-empty string which is no longer than 64 characters preallocated is a required boolean field with a default value false

is a required boolean field with a default value type is an optional field that can have one of the following values (enum): “multihomed”, “stub”, “transit” or “ixp”

is an optional field that can have one of the following values (enum): “multihomed”, “stub”, “transit” or “ixp” tags is an optional field that should be a possibly empty list of distinct alphanumeric string values, each string is no longer then 32 characters long

is an optional field that should be a possibly empty list of distinct alphanumeric string values, each string is no longer then 32 characters long ranges is a required array field that should have at least 1 entry

is a required array field that should have at least 1 entry Each entry in ranges array should have both first and last fields, which should be positive integers 1..65536. last should be greater than first (also validated on a back-end)

array should have both and fields, which should be positive integers 1..65536. should be greater than (also validated on a back-end) Entries in ranges array should not have overlapping ranges (i.e. 2-5 and 3-6 are invalid ranges). Can also be validated on a back-end.

I’ll be using Semantic UI form controls for styling and layout. One of the way to render a form for this payload on UI can look like this:

Almost all controls should be more or less intuitive, however, the ranges field is a tricky one. I decided to go with a table which user can manipulate via “Add a range” button (adds row to the table with 2 numeric input fields) and “trash” icon to remove a particular row.

Let’s define a minimal spec for our data model. We’ll emulate server-side validation errors separately, so I’ll omit describing validation logic for ranges in the spec

(s/def ::label (s/and string? #(<= 1 (count %) 64)))



(s/def ::preallocated boolean?)



(def supported-types #{"multihomed" "stub" "transit" "ixp"})

(s/def ::type supported-types)



(s/def ::tags (s/coll-of string?))



(s/def ::asn (s/and int? #(<= 1 % 65536)))

(s/def ::first ::asn)

(s/def ::last ::asn)

(s/def ::range (s/and (s/keys :req-un [::first ::last])

(fn [{:keys [first last]}]

(< first last))))



(s/def ::ranges (s/coll-of ::range :distinct true :min-count 1))



(s/def ::create-payload

(s/keys :req-un [::label ::preallocated ::ranges]

:opt-un [::tags ::type]))

Forms integration with re-frame

In order for forms to play nicely with re-frame we’ll need several things:

A path in app-db under which all forms-related stuff will be stored An event to set a value of any field in a form based on its’ path (useful trick is that get-in or assoc-in on [] path in payload data structure can set the whole form value, so we don’t need a specific subscription to A subscription to form field based on form id and relative field path in the form data structure Additional subscription for form-specific state (things like “submitting” state and so on). (Optional) a set of form controls to glue it together (for a single form it’s not very useful but when it comes to multiple forms it’s good to encapsulate input field boilerplate or checkbox/radio form controls in some reusable things. Even in our example we’ll have several similar inputs that should be refactored into reusable component in order not to DRY)

For the things that can potentially be reused across multiple projects, I prefer to create a top-level package at src/cljs directory so it can be later either copied to other project or extracted as a standalone lib. Let’s create forms package which will contain 2 files

core.cljs — the place for subscriptions/event handlers

— the place for subscriptions/event handlers controls.cljs — the place for all reusable controls

At this stage we only need core.cljs

(ns forms.core

(:require [re-frame.core :as re-frame]))





(def root-db-path [::forms])

(def value-db-path (conj root-db-path ::value))





(re-frame/reg-sub

::values

(fn [db]

(get-in db value-db-path)))





(re-frame/reg-sub

::field-value

:<- [::values]

(fn [forms-data [_ form-id field-path]]

(get-in forms-data (vec (cons form-id field-path)))))





(re-frame/reg-event-db

::set-field-value

(fn [db [_ form-id field-path new-value]]

(assoc-in db (vec (concat value-db-path (cons form-id field-path))) new-value)))

I know that re-frame docs strongly discourage generic subscriptions and event handlers that deal with dynamic paths but we’re using it here for simplicity of explanation. It’s also possible to expose more sophisticated event handlers that know how to deal with functions like update-in or wrap more advanced nested data structure manipulation tools like instar or specter.

In our example the simplest field to start with is label . With events and subscriptions from forms.core the field can be rendered as a standard input field like this:

(defn label-input [form-id]

(let [label-field-path [:label]

label-field-value @(re-frame/subscribe [::forms/field-value form-id label-field-path])]

[:div.ui.form

[:div.required.field

[:label "Label"]

[:input

{:type "text"

:value label-field-value

:on-change #(re-frame/dispatch [::forms/set-field-value form-id label-field-path (-> % .-target .-value)])}]]]))

This is a “hello world”-like example and is not very interesting. Let’s add some validation using spec:

(defn label-input [form-id]

(let [label-field-path [:label]

label-field-value @(re-frame/subscribe [::forms/field-value form-id label-field-path])

label-field-valid? (s/valid? ::model/label label-field-value)]



[:div.ui.form

[:div.required.field

{:class (when-not label-field-valid? "error")}

[:label "Label"]

[:input

{:type "text"

:value label-field-value

:on-change #(re-frame/dispatch [::forms/set-field-value form-id label-field-path (-> % .-target .-value)])}]



(when-not label-field-valid?

[:div.ui.pointing.red.basic.label "Label should be a string 1..64 chars long"])]]))

This example becomes a bit more interesting cause it conditionally renders validation errors.

In this function there is not a lot of form specific logic so we can do a simple refactoring and extract a generic input field component into our form controls library:

(defn text-input [form-id field-path field-spec validation-error-msg

& {:keys [label field-classes] :as options}]

(let [field-value @(re-frame/subscribe [::forms/field-value form-id field-path])

field-valid? (s/valid? field-spec field-value)]



[:div.required.field

{:class (cond->> (or field-classes (list))

(not field-valid?) (cons "error")

true (clojure.string/join \space))}



(when label

[:label "Label"])



[:input

{:type "text"

:value field-value

:on-change #(re-frame/dispatch [::forms/set-field-value form-id field-path (-> % .-target .-value)])}]



(when-not field-valid?

[:div.ui.pointing.red.basic.label validation-error-msg])]))

And then replace label field definition in the view with this (I prefer to keep functions signature for components short and then put everything which is not required under optional map as a last argument):

(defn label-input [form-id]

[:div.ui.form

[form-controls/text-input

form-id

[:label]

::model/label

"Label should be a string 1..64 chars long"

{:field-classes ["required"]

:label "Label"}]])

Now we have the first reusable form control. With slight modifications and the help of either partial functions or multimethods it’s not very hard to create components for number inputs, checkbox inputs, radio buttons, etc. following the same pattern (see corresponding code repository for more examples).

In most forms when there are choice controls (like type in this example) all available choices are not hardcoded but rather are provided via separate API call. Let’s assume that our back-end API exposes set of types via GET API and the response of this API call (after js->clj conversion) looks like this:

(def asn-types

[{:label "Multihoming" :value "multihomed"}

{:label "Stub network" :value "stub"}

{:label "Internet transit" :value "transit"}

{:label "Internet exchange point" :value "ixp"}])

In re-frame this API response will be saved somewhere in app-db and then there will be a subscription to get the value of API response. Let’s mock it:

(re-frame/reg-sub

::asn-types-api-response

(constantly asn-types))

When working with HTML form controls like radio buttons or selects, the tricky moment is that the same data source should be treated differently in order to properly handle label of the option, currently selected value and on-change event (it becomes even more interesting when these controls should be extracted into reusable controls which know how to deal with all these possibilities).

For our type field we can go with radio buttons that will look like this:

(defn types-radio-input [form-id]

(let [field-path [:type]

field-value @(re-frame/subscribe [::forms/field-value form-id field-path])]

(into [:div.ui.grouped.fields

[:label "Type"]]

(map (fn [{:keys [label value]}]

[:div.field

[:div.ui.radio.checkbox

[:input

{:type "radio"

:checked (= value field-value)

:on-change #(when (-> % .-target .-checked)

(re-frame/dispatch [::forms/set-field-value form-id field-path value]))}]

[:label label]]])

@(re-frame/subscribe [::subs/asn-types-api-response])))))

The same input can be represented with select dropdown:

(defn types-select-input [form-id]

(let [field-path [:type]

field-value @(re-frame/subscribe [::forms/field-value form-id field-path])]

(into [:select.ui.dropdown

{:value field-value

:on-change #(re-frame/dispatch [::forms/set-field-value form-id field-path (-> % .-target .-value)])}]

(map (fn [{:keys [label value]}]

[:option

{:value value}

label])

@(re-frame/subscribe [::subs/asn-types-api-response])))))

Due to underlying mechanics of dealing with form value is the same the only thing that changes is the way how to handle component layout and attributes themselves. For more or less standard it’s easy to extract these things into reusable components the same way as it was done with input . With unique controls (like the ones that designers provide to reinvent the dropdown) it always can be built from scratch following the same techniques.

Interdependent fields

Sometimes due to the complexity of the domain (or due to poorly designed data model), UI engineers have to deal with forms in which the change in one field should automatically cause change in other field(s) value (or do callbacks that are not related to field value itself). A common example would be an address form which has 2 dropdowns, the 1st is to select the state and the 2nd is to select the city in this state (let’s assume we’re looking for store locations in particular state and want to filter cities in the 2nd dropdown that are only applicable within this state; it’s also a good idea to reset the selection in 2nd dropdown completely when the value in the 1st dropdown has changed).

Most of the times this is the use case which can be a good “litmus paper test” to select forms framework to see how complex (if even possible) to implement this use case.

We can leverage re-frame do to it without a lot of hustle. There are couple of options (which are almost the same, except one is more testable):

Instead of using [::forms/set-field-value] directly for the events that should cause cascading changes in underlying data structure we can always create a new event handler using reg-event-fx that just conditionally dispatches appropriate ::form/set-field-value events

directly for the events that should cause cascading changes in underlying data structure we can always create a new event handler using that just conditionally dispatches appropriate events Put all the logic into on-change or on-click events for the required form controls.

For our ASN pools payload there might be a rule that says that “when type is set to "stub" then preallocated should be automatically set to true ". In type-radio-input we can modify the :on-change function to look like this:

#(when (-> % .-target .-checked)

(re-frame/dispatch [::forms/set-field-value form-id [:type] value])

(when (= value "stub")

(re-frame/dispatch [::forms/set-field-value form-id [:preallocated] true])))

Fields like type and preallocated are not very different from label field and I’ve added them to the sample payload just to showcase how checkboxes and radio buttons can be implemented with this approach (the code is available at github), however, the ranges field is quite different.

It’s a composition field and when user works with this field itself he just adds/removes an entry to ranges key in the data structure. However, user still needs to enter the values for the first and last fields in every range.

Luckily, this approach allows to manipulate with raw data structure

An “Add a range” button that conj’s an empty map to ranges array (it’s also possible to explicitly add {:first nil :last nil} but the 1st input change on any of the numeric fields will create a missing key in a range map on the fly A way to render all ranges as numeric inputs. ranges key is an array which means that every element in it can be accessed by index, i.e. get-in and assoc-in will work on a field path like [:ranges i :first] A “delete” button for each range that removes entry from the vector based on it’s index

The following implementation does the trick:

(defn ranges-input [form-id]

(let [ranges @(re-frame/subscribe [::forms/field-value form-id [:ranges]])]

[:div.ui.form

[:div.required.field

[:label "Ranges"]

(->> (range (count ranges))

(map (fn [i]

[:div.item

[:div.fields

[form-controls/number-input

form-id

[:ranges i :first]

::model/first

"Invalid ASN"

{:field-classes ["six" "wide"]}]



[form-controls/number-input

form-id

[:ranges i :last]

::model/first

"Invalid ASN"

{:field-classes ["six" "wide"]}]



[:div.four.wide.field

[:i.red.trash.link.icon

{:on-click #(re-frame/dispatch [::forms/set-field-value form-id [:ranges]

(utils/vec-remove ranges i)])}]]]]))

(into [:div.items]))



[:button.ui.mini.teal.button

{:on-click #(re-frame/dispatch [::forms/set-field-value form-id [:ranges]

(conj (or ranges []) {})])}

"Add a range"]]]))

At this point a curious reader might notice an interesting implication of doing validation this way: due to error message is mapped to the whole spec it might be confusing (and sometimes give false negatives for nested fields). This is especially painful when dealing with complex specs that have multiple predicates in their definition.

In our example, if I want to use spec for each particular range to verify that first is less then last I might encounter the following problem: ::model/range spec defines both the fact that data should be a map with 2 required keys as well as an additional predicate that verifies relationship between values in this map. When mapping error message via s/valid? I’m not taking into consideration which particular predicate returned false.