-
Declarative forms
-
Generate hiccup based on a declared form
-
Validation with error messages
-
Validation according to spec
-
External validation (e.g., compare against a value in a database)
-
-
Coercion
-
i18n support
-
Extendible
-
Support for namespaced keywords as field names
-
Anti-forgery support (can be turned off, but don’t do this)
-
Clojure support (using Hiccup, Replicant or some other library)
-
Clojurescript support (using Replicant)
defform takes three arguments.
-
form-name- The name of the form. This is used internally to keep track of which form is being used when the backend version is used -
meta-opts- This goes into the:metakeyword in the form for controlling how the form behaves -
fields- Which fields the form has
(require '[clojures.string :as str])
(require '[ez-form.core :as ezform :refer [defform]])
(defn present [s]
(not (str/blank? s)))
(def db (atom {:lastname "Bar"})
(defform myform
{}
[{:type :text
:label "First name"
:name :firstname
:attributes {:order 1} ;; additional parameters to add to the output HTML
:validation [{:spec present
:error-msg "First name must be present"}]}
{:type :text
:label "Last name"
:name :lastname
:validation [{:spec present
:error-msg "Last name must be present"}
{:external (fn [_field {:keys [field/value db]}]
(not= (:lastname @db) value))
:error-msg "Last name cannot be bar"}]
{:type :email
:name :myemail
:label "My email"
:validation [{:spec present
:error-msg "Email must be present"}
{:spec #(and (string? %)
(str/contains? % "@"))
:error-msg "Email must contain a @ character"}]}
{:type :number
:name :mynumber
:label "My number"
:coerce (fn [_field {:keys [field/value]}]
(parse-long value))}])
;; the form as a table
(let [data {:firstname "John"
:lastname "Doe"
:email "john.doe@example.com"}
table-opts {:attributes {:id "myform"
:class ["table" "striped"]}}]
[:form {:method :post :action "/myform"}
(ezform/as-table (myform data (:params request) {:db db}) table-opts)
[:input {:type :submit :value "Submit"}]])defform has support for the following meta options
-
form-name- What is the name of the form. Defaults to the name used when you use thedefformmacro. This controls if a form is actually being used or not in the scenario of multiple forms on the same page. -
field-order- In which order should the fields be rendered. Defaults to the order they were specified in. -
field-fns- Which field fns are available. Defaults to the errors field fn. -
extra-field-fns- Merged withfield-fns. -
fields- Which fields are supported (based on the:typespecified in the field) -
extra-fields- Merged withfields -
fns- Which meta functions are supported. Default includes the CSRF machinery -
extra-fns- Merged withfns -
validation- Which validation to use. Default is:spec. -
validation-fns- Validation fns. Default is the:specvalidate function. -
extra-validation-fns- Merged withvalidation-fns -
process?- Boolean value to turn on/off processing of the form. This is what processes each field with regards to attributes, value, coercions and errors.
Default supported fields are:
:button
:checkbox
:color
:date
:datetime-local
:email
:file
:hidden
:month
:number
:password
:radio
:range
:reset
:search
:select
:submit
:tel
:text
:textarea
:time
:url
:week
You can add new fields by specifying a defform with the following meta-option:
Here we add a shoelace web component for color picking.
(defn sl-input-color-picker [{:keys [type attributes]}]
[type attributes])
(defform myform
{:extra-fields {:sl-color-picker sl-input-color-picker}}
[{:name ::color
:type :sl-color-picker
:validation [{:spec #(not (str/blank? %))
:error-msg [:div.error "Color must be picked"]}]}]}render is the function that handles the actual rendering of the form
into hiccup. It deals with either a lookup which is expected to give
back hiccup or a function that gives back hiccup when run.
Render supports meta functions, rendering of the actual
field, lookup per field and lookup functions per field. If none
of the above are hit, it gives back the same hiccup as before.
(ezform/render form layout) => <hiccup to be rendered by a hiccup library>
(defform signupform
{}
[{:name :username
:type :text
:help [:i18n :ui.username/help]
:validation [{:spec #(not (str/blank? %))
:error-msg [:div.error :ui.username/not-blank]}]}
{:name :email
:type :email
:validation [{:spec #(not (str/blank? %))
:error-msg [:div.error :ui.email/not-blank]}]}]}
(let [form (myform {} (:params request))]
(ezform/render form
[:div.signup-form
[:h1 [:i18n :form.signup/title]]
[:h2 [:i18n :ui/username]]
[:fn/input-form-name]
[:fn/anti-forgery]
[:div
[:username]
[:div.help [:username :help]]
[:username :errors [:div.error :error]]]
[:div
[:email]
[:email :errors [:div.error :error]]]]))
=>
[:div.signup-form
[:h1 [:i18n :form.signup/title]]
[:h2 [:i18n :ui/username]]
[:input {:type :hidden :name :__ez-form_form-name :value "signup-form"}]
[:input {:id :__anti-forgery-token
:name :__anti-forgery-token
:value "my anti forgery token"
:type :hidden}]
[:div
[:input {:type :text
:id "signup-form-username"
:value nil
:name :username}]
[:div.help [:i18n :ui.username/help]]
()]
[:div
[:input {:type :email
:id "signup-form-email"
:value nil
:name :email}]
()]]Can be used for arbitrarily putting in hiccup. Default implementation includes anti-forgery. The meta functions only work for the entire form.
Hiccup that looks like this [:my-field-name], will trigger the
rendering of the field and give back valid hiccup for the type that
hte field has. This is what gives back an input text, radio buttons or
a color picker.
Hiccup that looks like this [:my-field-name :label] will trigger a
lookup for that field as defined in defform and return the value
therein.
In the below field a lookup via [:email :label] will give back "My
email". The lookup can be any valid hiccup, and so we can return
[:label "My email"] or [:i18n :email/label] for i18n translations.
{:type :email
:name :myemail
:label "My email"
:validation [{:spec present
:error-msg "Email must be present"}
{:spec #(and (string? %)
(str/contains? % "@"))
:error-msg "Email must contain a @ character"}]}Hiccup that has the form of lookup per field and also has a key
in [:meta :field-fns] in the form will run the function in there for
the field and replace the value with what is returned from the
function. The contrived example below shows how this can be done.
Usage is for things like i18n, where some i18n libraries don’t operate on pure hiccup, but instead needs to have functions run.
(defform myform
{:extra-field-fns {:fn/t (fn [_form _field [_ label]]
(str/capitalize (name label)))}}
[{:type :email
:name :myemail
:label [:fn/t :email/label]
:validation [{:spec present
:error-msg "Email must be present"}
{:spec #(and (string? %)
(str/contains? % "@"))
:error-msg "Email must contain a @ character"}]}])Tables are a very common way of rendering a form. as-table can be used to render a compact table form, with the option of changing the row layout.
;; render as is
(ezform/as-table (myform {} (:params request)))
;; render with table-opts
(ezform/as-table (myform {} (:params request))
{;; attributes will show up attributes for the table
:attributes {:class ["table" "striped"]}
;; row-layout allows for switching out the rendering
;; of the rows in the table
:row-layout (fn [field-k]
[:tr
[:th
[:label {:for [field-k :attributes :id]}
[field-k :label]]]
[:td
[field-k]
[field-k :help]
[field-k :errors [:div.error :error]]]])})
;; render with table-opts and meta-opts
(ezform/as-table (myform {} (:params request))
;; table opts
{;; attributes will show up attributes for the table
:attributes {:class ["table" "striped"]}
;; row-layout allows for switching out the rendering
;; of the rows in the table
:row-layout (fn [field-k]
[:tr
[:th
[:label {:for [field-k :attributes :id]}
[field-k :label]]]
[:td
[field-k]
[field-k :help]
[field-k :errors [:div.error :error]]]])}
;; meta opts
{:field-order [:email :firstname :lastname]})You can also render a form with as-template. In as-template the lookup
key is substituted with :field. as-template will go through all
fields in the form and render it according to the layout provided.
(as-template form [:div.layout
[:field]
[:field :errors :error]]);; will return a map of all the fields along with their associated values
(ezform/fields->map (myform nil (:params request))
=> {:firstname "Firstname", :lastname "Lastname", :email "firstname@lastname.com"}Validation is done by spec as default. Validation is extendible. Each
new validation type will need a validate function that is 2-arity and
takes field and a ctx map as arguments. The ctx map will have
field/value and fields as keys and anything that exists in the
:meta key inside the form itself.
Malli is supported out of the box.
External validation is supported via the :external key. In there a
function should be that takes field + ctx. This follows the same
pattern as the validate functions. By sending in things like a db, you
can do external validation against a db, file or something else.
Namespaced keywords are supported out of the box. Implementation wise,
you will see a lot of ! and ! in the name for fields if you use
namespaced keywords. The reason for this is that hiccup by default
renders only the name in namespaced keywords. Using ! and !
circumvents this and provides a nice mapping between . <→ ! and
/ <→ !. The reason for the ! at the end is that it’s not
uncommon to have an undercore in your name if the data comes from
somewhere outside the Clojure system (SQL database for
example), and so it’s used to avoid naming clashes. Just avoid using
! and ! as part of field names and everything should work
seamlessly.
ez-form uses ring.middleware.anti-forgery out of the box, but does not include it as a dependancy. As such, it needs to be included in any backend that uses ez-form, unless you specifically turn off CSRF protection (not advised).
For Clojure, CSRF protection will work seamlessly as long as the middleware is included in the middleware chain. For Clojurescript the anti-forgery token needs to be included in the meta options when a form is being rendered.
A POC exists in the dev directory, but it’s not fully fleshed out as it uses a normal POST which hooks in to the rest of the flow.
;; inside clojurescript
(myform {:anti-forgery-token <anti-forgery-token-here>} {} params)Alternative approaches:
-
Grab the form data on a click and send an AJAX call with a header that holds the CSRF token and the form data in the body.
-
Grab the form data when a submit happens and submit as FormData.
There are example implementations in dev/dev.clj
Import ez-form’s linting for defform with this.
clj-kondo --lint "$(clojure -Spath)" --copy-configs --skip-lint