Last active
December 20, 2017 13:55
-
-
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
;; 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])) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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)])}))) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage example for the
lib.forms
namespace above.