Skip to content

emil0r/ez-form

Repository files navigation

ez-form

ez-form is a data-driven library for forms using hiccup. With ez-form you declare your forms with data and render them as data.

Tested with Hiccup and Replicant for both Clojure and Clojurescript.

https://clojars.org/ez-form

Out of the box

  • 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

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 :meta keyword in the form for controlling how the form behaves

  • fields - Which fields the form has

Example usage
(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 meta-options

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 the defform macro. 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 with field-fns.

  • fields - Which fields are supported (based on the :type specified in the field)

  • extra-fields - Merged with fields

  • fns - Which meta functions are supported. Default includes the CSRF machinery

  • extra-fns - Merged with fns

  • validation - Which validation to use. Default is :spec.

  • validation-fns - Validation fns. Default is the :spec validate function.

  • extra-validation-fns - Merged with validation-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.

fields

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

Extending fields

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"]}]}]}

Rendering

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

Rendering meta functions

Can be used for arbitrarily putting in hiccup. Default implementation includes anti-forgery. The meta functions only work for the entire form.

Rendering of the actual field

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.

Lookup per field

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"}]}

Lookup functions per field

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"}]}])

as-table

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]})

Label and error messages

Default support for labels and error messages in as-table.

as-template

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

helper functions

;; 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

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.

i18n

i18n is supported and does not have a default implementation. m1p, tongue and tempura have implementation examples in the test suite.

Namespaced keywords

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.

Anti-forgery

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.

Anti-forgery Clojurescript

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.

Examples

There are example implementations in dev/dev.clj

linting

Import ez-form’s linting for defform with this.

clj-kondo --lint "$(clojure -Spath)" --copy-configs --skip-lint

License

Copyright © 2015-2025 Emil Bengtsson

Distributed under the MIT License.


Coram Deo

About

data-driven library for forms using hiccup

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published