Skip to content

Instantly share code, notes, and snippets.

@martinklepsch
Last active December 20, 2017 13:55
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save martinklepsch/e1366008c5a478b33c00d324314da4fd to your computer and use it in GitHub Desktop.
Save martinklepsch/e1366008c5a478b33c00d324314da4fd to your computer and use it in GitHub Desktop.
A form validation system for ClojureScript using funcool/struct. Besides plain validation it also has some support for showing validation issues only after the user has completed (on-blur) their input. That means the validation is not running eagerly with limited user input.
;; This was an earlier attempt using clojure.spec. I discarded it for reasons I don't remember.
(ns forms.core
(:require
#?(:cljs [cljs.spec :as s]
:clj [clojure.spec :as s])))
;; (s/def ::form-state
;; {::value s/Any
;; ::initial-value s/Any
;; ::errors s/Any
;; ::spec s/Any})
(defn alpha8-problems [probs]
(reduce-kv (fn [xs k v]
(conj xs (assoc v :path k)))
[]
(::s/problems probs)))
(defn explain-data [spec v]
(alpha8-problems (s/explain-data spec v)))
(defn ->fs
([value] (->fs value nil))
([value spec]
{::value value
::initial-value value
::errors [] ;(when spec (explain-data spec value))
::dirty #{}
::spec spec}))
(defn reset [fs]
(assoc fs ::value (::initial-value fs)))
(defn valid? [fs]
(nil? (::errors fs)))
(defn validate [fs]
(let [errors (if-let [spec (::spec fs)]
(explain-data spec (::value fs)))]
(assoc fs
::errors errors
::validated ::all-fields)))
(defn validate-dirty [fs]
(if (= ::all-fields (::validated fs))
(validate fs)
(let [errors (if-let [spec (::spec fs)]
(->> (explain-data spec (::value fs))
(filter #(contains? (::dirty fs) (:in %)))))]
(assoc fs
::errors errors
::validated (::dirty fs)))))
(defn input [fs path value validate?]
(let [fs (-> (assoc-in fs (into [::value] path) value)
(update ::dirty conj path))]
(if validate? (validate-dirty fs) fs)))
(defn errors-for-path [fs path]
(first (filter #(= (:in %) path) (::errors fs))))
(defn path-info [fs path]
(let [error (errors-for-path fs path)
validated (or (= ::all-fields (::validated fs))
(contains? (::validated fs) path))]
{:dirty? (contains? (::dirty fs) path)
:valid? (and validated (nil? error))
:validated? validated
:value (get-in fs (into [::value] path))
:error error
:path path}))
(def nested-form
(s/keys :req [:oc.forms/email-form]))
(ns lib.forms
"Utilities to manage, update and validate user-facing inputs"
(:require [schema.core :as s]
[struct.core :as struct]
[clojure.string :as string]
[re-frame.core :as rf]))
;; DESIGN / CONSTRAINTS
;; limit to one-dimensional forms or at least associative structures
;;
;; using schema for form validation is slightly limiting and not
;; really what it was designed for. turning schema validation errors
;; into something that can be shown to a user would have been a lot
;; of work
;;
;; FUTURE
;; - consider coercion as a possible extension of this
(defprotocol IExplain
;; This protocol might be a bit overkill but it allows us to swap
;; validation libries without affecting the underlying FormData implementation
(explain [this value]))
(defrecord ExplainStruct [struct-schema]
IExplain
(explain [this value] (first (struct/validate value struct-schema))))
(defrecord ExplainSchema [schema]
IExplain
(explain [this value] (s/check schema value)))
(defprotocol IFormData
(input [_ path value validate?])
(validate [_])
(validate-dirty [_])
(valid? [_]))
(defrecord FormData [value initial-value errors dirty schema]
IFormData
(validate [this]
(let [errors (when schema (explain schema value))]
(assoc this
:errors errors
:validated ::all-fields)))
(validate-dirty [this]
(if (= ::all-fields (:validated this))
(validate this)
(let [errors (when schema
(let [all-errors (explain schema value)]
(reduce (fn [acc path]
(if-let [e (get-in all-errors path)]
(assoc-in acc path e)
acc))
{}
dirty)))]
(assoc this
:errors errors
:validated dirty))))
(input [this path value validate?]
(let [updated (-> (assoc-in this (into [:value] path) value)
(update :dirty conj path))]
(if validate? (validate-dirty updated) updated)))
(valid? [this]
(-> this validate :errors seq nil?)))
(defn reset [fd]
(assoc fd :value (:initial-value fd)))
(defn form-data
([value] (form-data value nil))
([value schema]
(when schema (assert (implements? IExplain schema)))
(map->FormData {:value value
:initial-value value
:errors {} ;(when schema (explain schema value))
:dirty #{}
:schema schema})))
(defn path-info [fd path]
(let [validated (or (= ::all-fields (:validated fd))
(contains? (:validated fd) path))
error (get-in fd (into [:errors] path))]
{:dirty? (contains? (:dirty fd) path)
:valid? (and validated (nil? error))
:validated? validated
:value (get-in fd (into [:value] path))
:error error
:path path}))
;; ==============================================================================
;; -- Re-frame specific bits and pieces -----------------------------------------
;; ==============================================================================
(def app-db-key ::forms)
;; create new form
(rf/reg-event-db
::init
[rf/trim-v (rf/path app-db-key)]
(fn [forms [form-id schema init-val]]
(if-let [existing (get forms form-id)]
(assoc forms form-id (assoc existing :schema schema))
(assoc forms form-id (form-data init-val schema)))))
;; input handler
(rf/reg-event-db
::input
[rf/trim-v (rf/path app-db-key)]
(fn [forms [form-id path value validate?]]
(update forms form-id input path value validate?)))
;; input handler
(rf/reg-event-db
::validate
[rf/trim-v (rf/path app-db-key)]
(fn [forms [form-id]]
(update forms form-id validate)))
;; reset a form
(rf/reg-event-db
::reset
[rf/trim-v (rf/path app-db-key)]
(fn [forms [form-id]]
(update forms form-id reset)))
;; TODO if needed (mk): clear all forms ::clear-all
(rf/reg-sub
::form
(fn form-sub [db [_ form-id]]
(assert form-id "A Form ID is required")
(get-in db [app-db-key form-id])))
(defn get-field [db form-id field-path]
(when-not (get-in db [app-db-key form-id])
(js/console.warn "Unknown form" (pr-str form-id)))
(get-in db (into [app-db-key form-id :value] field-path)))
(defn get-form-val [db form-id]
(when-not (get-in db [app-db-key form-id])
(js/console.warn "Unknown form" (pr-str form-id)))
(get-in db [app-db-key form-id :value]))
(defn input-attrs
([form-id path-info] (input-attrs form-id path-info (fn [e] (.. e -target -value))))
([form-id path-info extract]
(let [error (:error path-info)]
{:type "text"
:value (or (:value path-info) "")
:on-blur #(when (or (:dirty? path-info) (not (string/blank? (extract %))))
(rf/dispatch [::input form-id (:path path-info) (extract %) true]))
:on-change #(rf/dispatch [::input form-id (:path path-info) (extract %) (boolean error)])})))
@martinklepsch
Copy link
Author

Usage example for the lib.forms namespace above.

(def CreateAccountValidation
  (let [eight-chars {:message "must be at least 8 characters long"
                     :validate (fn [v] (<= 8 (count v)))}]
    (lib-forms/->ExplainStruct
     [[:email struct/email]
      [:password struct/string eight-chars]
      [:password-confirmation eight-chars [struct/identical-to :password]]])))

(defn create-account-form []
  (let [form-id ::create-account
        form (rf/subscribe [::lib-forms/form form-id])
        auth (rf/subscribe [:auth])]
    (rf/dispatch-sync [::lib-forms/init form-id CreateAccountValidation {}])
    [:form
     (let [pi (lib-forms/path-info @form [:email])]
       [:fieldset
        [:label "Email"]
        [:input (lib-forms/input-attrs form-id pi)]
        (when (:error pi) [:div.error (:error pi)])])

     (let [pi (lib-forms/path-info @form [:password])]
       [:fieldset
        [:label "Password"]
        [:input (merge (lib-forms/input-attrs form-id pi) {:type "password"})]
        (when (:error pi) [:div.error (:error pi)])])

     (let [pi (lib-forms/path-info @form [:password-confirmation])]
       [:fieldset
        [:label "Confirm Password"]
        [:input (merge (lib-forms/input-attrs form-id pi) {:type "password"})]
        (when (:error pi) [:div.error (:error pi)])])

     (when-let [err-msg (:error-message @auth)]
       [:p.error err-msg])

     [:fieldset
      [:button.primary
       {:on-click #(if-not (lib-forms/valid? @form)
                     (rf/dispatch [::lib-forms/validate form-id])
                     (rf/dispatch [:auth/create-user (select-keys (:value @form) [:email :password])]))
        :type "button"}
       "Sign Up"]]]))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment