May 21, 2021

Building a library IX - Branching and wizards

Every so often, you come across requirements from clients, that require you to change a form, depending on input from the user of the form. This is called branching (as each choice leads to a new branch of a possible form). Wizards could be considered very simple versions of branching.

Branching can be used for many useful things, and apart from wizards, you can use them for mutually exclusive fields, mirrored fields, fields adjusting content based on choices made by the user and completely different branches of a form, where a choice leads to widly different choices further down (can be hidden via a wizard).

In order to support this, we will have to change most of the code we so far have written, so bear with me.

Extending our form

First, let’s extend our form to handle branching. For each field that is to be affected by branching, we want to be able to add our logic. We want to support showing and hiding fields, as well as changing the fields themselves.

(defform my-form-name
  {:branch/branching? true
   :branch/branches {:citizen (fn [{:keys [value] :as _context}]
                                (condp = value
                                  {:show-fields [:discount]
                                   :fields {:cost {:value "$6"}
                                            :discount {:value "40%"}}}

                                  {:show-fields [:discount]
                                   :fields {:cost {:value "$8"}
                                            :discount {:value "20%"}}}

                                  ;; default
                                  ;; needs one for initiation of the form
                                  {:hide-fields [:discount]
                                   :fields {:cost {:value "$10"}}}))}}
  [{:name :first-name
    :element Input
    :adapter input-adapter
    :validation [:text-required]}
   {:name :last-name
    :element Input
    :adapter input-adapter
    :validation [:text-required]}
   {:name :email
    :element Input
    :adapter email-adapter
    :validation [:email-validation]}
   {:name :citizen
    :element RadioGroup ;; from some popular React component library
    :adapter radio-group-adapter
    :value nil
    :options ["Junior" "Adult" "Senior"]}
   {:name :discount
    :element Text
    :adapter text-adapter
    :active? false
    :label "Discount"
    :value "30%"}
   {:name :cost
    :element Text
    :adapter text-adapter
    :label "Cost"
    :value "$10"}])

Our new form has first-name, last-name, email, citizen and cost. If you are a junior or senior citizen, you get a discount, which is reflected immediately in the form as you click on either junior or senior. The discount is different depending on which one you click, and we also want to tell our customer how much the discount is in percentage.

Extending the watcher

(defn- finalize-error-message [context error-message]
  (if (fn? error-message)
    (with-meta (error-message context) {::by-fn? true})

(defn- get-error-messages [{:keys [validation] :as field} value form]
  (let [v (if (sequential? validation) validation [validation])
        context {:field field
                 :value value
                 :form form}]
    (->> v
         (remove #(valid? % value form))
         (map (comp #(finalize-error-message context %)
                    #(get-error-message % value form)))
         (remove nil?))))

(defn- field-updated?
  [old-state k v]
  ;; we skip pairs of nils assuming that this is a state of initialization
  (let [old-v (get old-state k)]
    (and (not (and (nil? old-v)
                   (nil? v)))
         (not= v old-v))))

(defn- get-validation-errors [form new-state old-state]
  (fn [out [k v]]
    (let [{:keys [validation] :as field} (get-in form [:fields k])]
      ;; update? is used to determine if we should do updates to errors
      ;; we still need all the errors for on-valid to properly fire
      (let [affected-fields (get-affected-fields validation)
            update? (if (empty? affected-fields)
                      (field-updated? old-state k v)
                      ;; we need to run two checks here. first that the current field
                      ;; has been updated at least once. if this does not pass
                      ;; we do nothing, because the user has not begun to interact with the
                      ;; field
                      ;; if it passes that we run the check against affected fields,
                      ;; including the current field
                      (and (some? v)
                           (some #(field-updated? old-state % (get new-state %))
                                 (set/union affected-fields #{k}))))]
        (if (valid? validation v form)
          ;; if the validation is valid we change it to hold zero errors
          (conj out [k update? []])
          ;; if the validation is invalid we give an explanation to
          ;; what's wrong
          (conj out [k update? (get-error-messages field v form)]))))))

(defn- get-external-errors [form field-errors]
  (reduce (fn [out [k update? errors]]
            (if-let [external-errors (get-in @(:extra form) [k :field-errors])]
              (let [trimmed-external-errors (remove #(valid? % nil nil) external-errors)]
                ;; run a check if we need to update the external errors
                ;; because some of them are being removed
                (if-not (= (count external-errors)
                           (count trimmed-external-errors))
                  (swap! (:extra form) assoc-in [k :field-errors] trimmed-external-errors))
                (conj out [k update? (->> trimmed-external-errors
                                              (map #(get-error-message % nil nil))
                                              (into errors))]))
              (conj out [k update? errors])))
          [] field-errors))

(defn- handle-branching [form field-name value]
  (if-let [f (get-in form [:options :branch/branches field-name])]
    (let [result (f {:form form :field-k field-name :value value})
          {:keys [show-fields hide-fields fields]} result
          show-fields (set (if (= show-fields :all) (:field-ks form) show-fields))
          show-fields (if (:exclude-branching-field? result)
                        (conj show-fields field-name))
          hide-fields (set (if (= hide-fields :all) (:field-ks form) hide-fields))
          to-hide (set/difference hide-fields show-fields)]
      ;; hide fields
      (doseq [k to-hide]
        (reset! (get-in form [:fields k :active?]) false))
      ;; show fields
      (doseq [k show-fields]
        (reset! (get-in form [:fields k :active?]) true))
      ;; update branching RAtom
      (swap! (:branching form) assoc field-name fields)
      ;; run a special update against the data RAtom
      (let [update-model-values (->> fields
                                     (filter (fn [[k v]]
                                               (some? (:model v))))
                                     (map (fn [[k v]]
                                            [k (:model v)]))
                                     (into {}))]
        (swap! (:data form) merge update-model-values)))))

(defn- get-changed-field [old-state new-state]
  (reduce (fn [out [k value]]
            (if (field-updated? old-state k value)
              (reduced k)
          nil new-state))

(defn- add-watcher
  "Add validation and branching checks for the RAtom as it changes"
  (let [{{on-valid :on-valid} :options} form
        ;; Watcher is manually run when initiating a form. We
        ;; do not wish to run branching during the initiation however,
        ;; so we add this little variable for initiation to handle
        ;; that problem
        branching-initiated? (clojure.core/atom false)
        use-branching? (get-in form [:options :branch/branching?])]
    (add-watch (:data form) (:id form)
               (fn [_ _ old-state new-state]
                 (let [changed-field-k (get-changed-field old-state new-state)]
                  ;; get all errors for all fields
                  (let [field-errors (->> (reduce (get-validation-errors form new-state old-state) [] new-state)
                                          (get-external-errors form))]
                    ;; update the RAtoms for the error map
                    (doseq [[k update? errors] field-errors]
                      (when update?
                        (rf/dispatch [::error (:id form) k errors])
                        (reset! (get-in form [:errors k]) errors)))

                    ;; handle branching before validity
                    ;; validity depends on the updated branching to decide
                    ;; which fields are active or not.
                    ;; branching initiation is also handled here.
                    (if @branching-initiated?
                      (when (and use-branching?
                        (handle-branching form changed-field-k (get new-state changed-field-k)))
                      (reset! branching-initiated? true))

                    ;; if there are no errors then the form is
                    ;; valid and we can fire off the function
                    (let [active-fields (reduce (fn [out k]
                                                  (if @(:active? (get-in form [:fields k]))
                                                    (conj out k)
                                                #{} (:field-ks form))
                          valid? (->> field-errors
                                      (filter #(active-fields (first %)))
                                      (map last)
                                      (every? empty?))
                          to-send (if valid? (select-keys @(:data form) active-fields) ::invalid)]
                      (when (fn? on-valid)
                        (on-valid to-send))
                      (rf/dispatch [::on-valid (:id form) to-send]))))))))

Yeah…​ that’s a lot of code to digest. It’s almost the same as part V, but we added three crucial things. In watcher we add checks for branches, first by finding out which key in our form data has changed, and then run our checks against the branches (if any). This is done after we have done our validation checks, as we otherwise can run into nasty loops and unexpected behaviour.

The third thing we added was the handle-branching function, which takes our form, the field-name (they key to the :data in our form) and the updated value. Given a function in branches for our field-name, we run the function, get back our result and run our due course with showing/hiding fields, adding data to our :branching RAtom in our form and finally, see if we need to update our :data RAtom in the form for any of our fields.

With this function, we can hide/show fields, add/remove new data for the fields (can be used to update options in a dropdown for example) and update the data for fields.

The functionality is not entirely seemless. Any field that wishes to take full advantage of branching, will need to have adapters, or be specifically written for branching, to take full advantage.

Extending common rendering

We also need to extend our common rendering functions.

(defn- get-branching-args [branch-data]
  (reduce merge (vals branch-data)))

(defn render-field [{:keys [field-fn name] :as field} {:keys [branching] :as form}]
  [field-fn (-> field
                (merge (get (get-branching-args @branching) name))
                (assoc :model (reagent/cursor (:data form) [name]))
                (dissoc :wiring :template :label))])

(defn render-text [{:keys [field-fn text css] :as field} form]
  (if text
    [:div {:class (get css :text "text")} (t text)]))

(defn render-help [{:keys [field-fn help css] :as field} form]
  (if help
    [:div {:class (get css :help "help")} (t help)]))

(defn render-label [{:keys [css id label name] :as field} form]
  (if (false? label)
    [:label {:for id :class (get css :label "label")} (t (or label name))]))

(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)

                 (:wiring field)))))

(defn render-wiring [{:keys [wiring active?] :as field} form]
  (let [rendered (wiring/wiring [:$wrapper
                                {:$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 [{:keys [active?]} _]
      (when @active?
        (into [:<>] rendered)))))

(defn- get-wizard-fields [form params]
  (let [current-step (get @(:wizard form) :current-step)
        current-fields (get-in form [:options :wizard :steps current-step :fields])]
    (map (comp #(assoc-wiring % params)
               #(get-in form [:fields %])) current-fields)))

(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]
      (let [fields (if (wizard? form)
                     (get-wizard-fields form params)
         (for [field fields]
           (if (:wiring field)
             ^{:key (:id field)}
             [render-wiring field form]
             ^{:key (:id field)}
             [row-fn field form]))]))))

This is almost the same as part VI, but we now add checks for @active?, which is what we use for turning off/on a field showing. We’re also adding get-wizard-fields, which uses @active? as well, but with simpler implementation details.

In render-field we grab any data from @branching and merge it into our field data that comes from initiation of the form and initiation of the rendering. We set it so that it overrides anything that exists in the default data for the field.

Adding our wizard

The wizard by itself is slightly involved, but is currently the least extendible part of ez-wire. I include it mostly for completeness sake.

Noteworthy about the wizard, is that it takes the other rendering functions for the form, wraps them and then either handles the rendering from there or unwraps it if the form is not a wizard, so as to incurr a minimum performance penalty.

(defn show-form-fn
  "Show the form with the fn chosen, can send in a new field-ks"
  ([form-fn {:keys [params form content]}]
   [form-fn params form content])
  ([form-fn current-step {:keys [form] :as args}]
   (swap! (:wizard form) assoc :current-step current-step)
   [show-form-fn form-fn args]))

(defn render-navigation [{:keys [step last-step? first-step? max-steps form button css button-props]}]
  (r/with-let [data (rf/subscribe [:ez-wire.form/on-valid (:id form)])
               valid-fn (get-in form [:options :wizard :valid-fn])]
    (let [valid? (helpers/valid? data)]
      [:div {:class (get css :pagination "pagination")}
       [:div {:class (get css :prev "prev")}
        [button {:disabled first-step?
                 :class (get css :button "btn primary")
                 :on-click #(reset! step (max 0 (dec @step)))}
         (t ::prev)]]
       [:div {:class (get css :next "next")}
        [button (merge
                 {:disabled (and last-step? (not valid?))
                  :class (get css :button)
                  :on-click #(cond (not last-step?)
                                   (reset! step (min max-steps (inc @step)))

                                   (and last-step? valid? valid-fn)
                                   (valid-fn @data)

         (if (and last-step? valid?)
           (t :ez-wire.form/done)
           (t ::next))]]])))

(defn render-step [{:keys [step step-opts max-steps css] :as data}
                   form-fn {:keys [id] :as form} args]
  (let [{:keys [legend]} step-opts
        first-step? (zero? @step)
        last-step? (= (dec max-steps) @step)
        render-navigation (or (get-in form [:options :wizard :render-navigation])
    [:div {:class (get css :wizard "wizard")
           :key (str "wizard-" id)}
     [:div {:class (get css :legend "legend")} legend]
     [show-form-fn form-fn @step args]
     [render-navigation (assoc data
                               :first-step? first-step?
                               :last-step? last-step?
                               :form form
                               :max-steps max-steps)]]))

(defn run-wizard [form-fn args]
  (let [step (r/atom 0)
        {:keys [form]} args
        default-style-map {:min-height (as-> (get-in form [:options :wizard :steps]) $
                                             (map count $)
                                             (apply max $)
                                             (str (* 10 $) "rem"))}
        max-steps (count (get-in form [:options :wizard :steps]))
        button-element (get-in form [:options :wizard :button/element] elements/button-element)
        button-props (get-in form [:options :wizard :button/props] {})]
    (fn [form-fn args]
      (let [{:keys [params form]} args
            step-opts (get-in form [:options :wizard :steps @step])
            style-map {:style (merge default-style-map
                                     (get-in form [:options :wizard :css])
                                     (get-in params [:wizard :css])
                                     (get-in step-opts [:css]))}]
        (rf/dispatch [:ez-wire.form.wizard/current-step (:id form) @step])
        [render-step {:step step
                      :step-opts step-opts
                      :style-map style-map
                      :max-steps max-steps
                      :button button-element
                      :button-props button-props}
         form-fn form args]))))

(defn wizard
  "Takes a form-fn (as-table, as-list, etc) and puts into a surrounding wizard
  if the form is to be rendered as a wizard"
  (fn [params form & [content]]
    (let [wizard? (helpers/wizard? form)]
      (fn [params form & [content]]
        (let [args {:params params :form form :content content}]
          (if wizard?
            [run-wizard form-fn args]
            (show-form-fn form-fn args)))))))

The logic is as follows: Wrap a rendering function in wizard. If the form is a wizard, it’s run in run-wizard. A wizard is divided into multiple steps, with x amount of fields per step, and render-step is what renders each step. render-navigation is our non-extendible pagination for the wizard.

And it’s defined as follows in our form. Notice how we can extend our form to so much new functionality, without having to break any code. The power of declarative syntax and pure data :).

(defn view []
  (let [my-form (my-form-name {:render :wizard
                               :wizard {:steps [{:fields [:first-name :last-name]
                                                 :legend [:h3 "Part 1"]}
                                                {:fields [:email]
                                                 :legend [:h3 "Part 2"]}
                                                {:fields [::citizen]
                                                 :legend [:h3 "Part 3"]}]}}
    [as-table my-form {}]))

End of the road

I hope this has been fruitful to read. If you want a real deep dive into this, take a look at ez-wire and Django forms.

Tags: programming