Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show validation errors to user when creating a new form #1308

Merged
merged 2 commits into from Jun 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/translations/en.edn
Expand Up @@ -246,6 +246,7 @@
:user "User"
:validation {:errors "There were problems. Check the following fields."
:invalid-user "Invalid user."
:invalid-value "Invalid value."
:required "Field \"%1\" is required."
:toolong "Answer to field \"%1\" is too long."}}
:header {:title "Welcome to REMS"}
Expand Down
1 change: 1 addition & 0 deletions resources/translations/fi.edn
Expand Up @@ -246,6 +246,7 @@
:user "Käyttäjä"
:validation {:errors "Lähetys epäonnistui. Tarkista seuraavat kentät."
:invalid-user "Käyttäjä ei kelpaa."
:invalid-value "Arvo ei kelpaa."
:required "Kenttä \"%1\" on pakollinen."
:toolong "Kentän \"%1\" vastaus on liian pitkä."}}
:header {:title "Tervetuloa REMSiin"}
Expand Down
43 changes: 34 additions & 9 deletions src/cljs/rems/administration/components.cljs
Expand Up @@ -15,7 +15,8 @@
:label - String, shown to the user as-is."
(:require [clojure.string :as str]
[re-frame.core :as rf]
[rems.atoms :refer [info-field textarea]]))
[rems.atoms :refer [info-field textarea]]
[rems.text :refer [text-format]]))

(defn- key-to-id [key]
(if (number? key)
Expand All @@ -27,20 +28,29 @@
(map key-to-id)
(str/join "-")))

(defn- field-validation-message [error label]
[:div {:class "invalid-feedback"}
(when error (text-format error label))])

(defn input-field [{:keys [keys label placeholder context type normalizer readonly]}]
(let [form @(rf/subscribe [(:get-form context)])
form-errors (when (:get-form-errors context)
@(rf/subscribe [(:get-form-errors context)]))
id (keys-to-id keys)
normalizer (or normalizer identity)]
normalizer (or normalizer identity)
error (get-in form-errors keys)]
[:div.form-group.field
[:label {:for id} label]
[:input.form-control {:type type
:id id
:disabled readonly
:placeholder placeholder
:class (when error "is-invalid")
:value (get-in form keys)
:on-change #(rf/dispatch [(:update-form context)
keys
(normalizer (.. % -target -value))])}]]))
(normalizer (.. % -target -value))])}]
[field-validation-message error label]]))

(defn text-field
"A basic text field, full page width."
Expand All @@ -51,30 +61,40 @@
"A basic textarea, full page width."
[context {:keys [keys label placeholder]}]
(let [form @(rf/subscribe [(:get-form context)])
id (keys-to-id keys)]
form-errors (when (:get-form-errors context)
@(rf/subscribe [(:get-form-errors context)]))
id (keys-to-id keys)
error (get-in form-errors keys)]
[:div.form-group.field
[:label {:for id} label]
[textarea {:id id
:placeholder placeholder
:value (get-in form keys)
:class (when error "is-invalid")
:on-change #(rf/dispatch [(:update-form context)
keys
(.. % -target -value)])}]]))
(.. % -target -value)])}]
[field-validation-message error label]]))

(defn- localized-text-field-lang [context {:keys [keys-prefix lang]}]
(defn- localized-text-field-lang [context {:keys [keys-prefix label lang]}]
(let [form @(rf/subscribe [(:get-form context)])
form-errors (when (:get-form-errors context)
@(rf/subscribe [(:get-form-errors context)]))
keys (conj keys-prefix lang)
id (keys-to-id keys)]
id (keys-to-id keys)
error (get-in form-errors keys)]
[:div.form-group.row
[:label.col-sm-1.col-form-label {:for id}
(str/upper-case (name lang))]
[:div.col-sm-11
[textarea {:id id
:min-rows 1
:value (get-in form keys)
:class (when error "is-invalid")
:on-change #(rf/dispatch [(:update-form context)
keys
(.. % -target -value)])}]]]))
(.. % -target -value)])}]
[field-validation-message error label]]]))

(defn localized-text-field
"A text field for inputting text in all supported languages.
Expand All @@ -86,6 +106,7 @@
[:label label]]
(for [lang languages]
[localized-text-field-lang context {:keys-prefix keys
:label label
:lang lang}]))))

(defn checkbox
Expand All @@ -106,14 +127,18 @@

(defn- radio-button [context {:keys [keys value label orientation readonly]}]
(let [form @(rf/subscribe [(:get-form context)])
form-errors (when (:get-form-errors context)
@(rf/subscribe [(:get-form-errors context)]))
name (keys-to-id keys)
id (keys-to-id (conj keys value))]
id (keys-to-id (conj keys value))
error (get-in form-errors keys)]
[(case orientation
:vertical :div.form-check
:horizontal :div.form-check.form-check-inline)
[:input.form-check-input {:id id
:type "radio"
:disabled readonly
:class (when error "is-invalid")
:name name
:value value
:checked (= value (get-in form keys))
Expand Down
116 changes: 59 additions & 57 deletions src/cljs/rems/administration/create_form.cljs
Expand Up @@ -10,7 +10,7 @@
[rems.fields :as fields]
[rems.status-modal :as status-modal]
[rems.text :refer [text text-format]]
[rems.util :refer [dispatch! post! normalize-option-key parse-int]]))
[rems.util :refer [dispatch! post! normalize-option-key parse-int remove-empty-keys]]))

(rf/reg-event-fx
::enter-page
Expand All @@ -21,6 +21,7 @@

;; TODO rename item->field
(rf/reg-sub ::form (fn [db _] (::form db)))
(rf/reg-sub ::form-errors (fn [db _] (::form-errors db)))
(rf/reg-event-db ::set-form-field (fn [db [_ keys value]] (assoc-in db (concat [::form] keys) value)))

(rf/reg-event-db ::add-form-field (fn [db [_]] (update-in db [::form :fields] items/add {:type "text"})))
Expand Down Expand Up @@ -48,7 +49,6 @@
(fn [db [_ field-index option-index]]
(update-in db [::form :fields field-index :options] items/move-down option-index)))


;;;; form submit

(defn- supports-optional? [field]
Expand All @@ -63,47 +63,6 @@
(defn- supports-options? [field]
(contains? #{"option" "multiselect"} (:type field)))

(defn- localized-string? [lstr languages]
(and (= (set (keys lstr))
(set languages))
(every? string? (vals lstr))))

(defn- valid-required-localized-string? [lstr languages]
(and (localized-string? lstr languages)
(every? #(not (str/blank? %))
(vals lstr))))

(defn- valid-optional-localized-string? [lstr languages]
(and (localized-string? lstr languages)
;; partial translations are not allowed
(or (every? #(not (str/blank? %))
(vals lstr))
(every? str/blank?
(vals lstr)))))

(defn- valid-option? [option languages]
(and (not (str/blank? (:key option)))
(valid-required-localized-string? (:label option) languages)))

(defn- valid-request-field? [field languages]
(and (valid-required-localized-string? (:title field) languages)
(not (str/blank? (:type field)))
(boolean? (:optional field))
(if (supports-input-prompt? field)
(valid-optional-localized-string? (:input-prompt field) languages)
(nil? (:input-prompt field)))
(if (supports-maxlength? field)
(not (neg? (:maxlength field)))
(nil? (:maxlength field)))
(if (supports-options? field)
(every? #(valid-option? % languages) (:options field))
(nil? (:options field)))))

(defn- valid-request? [request languages]
(and (not (str/blank? (:organization request)))
(not (str/blank? (:title request)))
(every? #(valid-request-field? % languages) (:fields request))))

(defn build-localized-string [lstr languages]
(into {} (for [language languages]
[language (get lstr language "")])))
Expand All @@ -124,26 +83,70 @@
:label (build-localized-string label languages)})})))

(defn build-request [form languages]
(let [request {:organization (:organization form)
:title (:title form)
:fields (mapv #(build-request-field % languages) (:fields form))}]
(when (valid-request? request languages)
request)))
{:organization (:organization form)
:title (:title form)
:fields (mapv #(build-request-field % languages) (:fields form))})

;;;; form validation

(defn- validate-text-field [m key]
(when (str/blank? (get m key))
{key :t.form.validation/required}))

(defn- validate-localized-text-field [m key languages]
{key (apply merge (mapv #(validate-text-field (get m key) %) languages))})

(defn- validate-optional-localized-field [m key languages]
(let [validated (mapv #(validate-text-field (get m key) %) languages)]
;; partial translations are not allowed
(when (not-empty (remove identity validated))
{key (apply merge validated)})))

(def ^:private maxlength-range [0 32767])

(defn- validate-maxlength [maxlength]
(when-not (str/blank? maxlength)
(let [parsed (parse-int maxlength)]
(when (or (nil? parsed)
(not (<= (first maxlength-range) parsed (second maxlength-range))))
{:maxlength :t.form.validation/invalid-value}))))

(defn- validate-option [option id languages]
{id (merge (validate-text-field option :key)
(validate-localized-text-field option :label languages))})

(defn- validate-options [options languages]
{:options (apply merge (mapv #(validate-option %1 %2 languages) options (range)))})

(defn- validate-field [field id languages]
{id (merge (validate-text-field field :type)
(validate-localized-text-field field :title languages)
(validate-optional-localized-field field :input-prompt languages)
(validate-maxlength (:maxlength field))
(validate-options (:options field) languages))})

(defn validate-form [form languages]
(-> (merge (validate-text-field form :organization)
(validate-text-field form :title)
{:fields (apply merge (mapv #(validate-field %1 %2 languages) (form :fields) (range)))})
remove-empty-keys))

(rf/reg-event-fx
::create-form
(fn [_ [_ request]]
(status-modal/common-pending-handler! (text :t.administration/create-form))
(post! "/api/forms/create" {:params request
:handler (partial status-modal/common-success-handler! #(dispatch! (str "#/administration/forms/" (:id %))))
:error-handler status-modal/common-error-handler!})
{}))

(fn [{:keys [db]} [_ request]]
(let [form-errors (validate-form (db ::form) (db :languages))]
(when (empty? form-errors)
(status-modal/common-pending-handler! (text :t.administration/create-form))
(post! "/api/forms/create" {:params request
:handler (partial status-modal/common-success-handler! #(dispatch! (str "#/administration/forms/" (:id %))))
:error-handler status-modal/common-error-handler!}))
{:db (assoc db ::form-errors form-errors)})))

;;;; UI

(def ^:private context
{:get-form ::form
:get-form-errors ::form-errors
:update-form ::set-form-field})

(defn- form-organization-field []
Expand Down Expand Up @@ -244,8 +247,7 @@
request (build-request form languages)]
[:button.btn.btn-primary
{:type :button
:on-click #(on-click request)
:disabled (nil? request)}
:on-click #(on-click request)}
(text :t.administration/save)]))

(defn- cancel-button []
Expand Down
11 changes: 11 additions & 0 deletions src/cljs/rems/util.cljs
Expand Up @@ -19,6 +19,17 @@
[m ks]
(reduce getx m ks))

(defn remove-empty-keys
"Given a map, recursively remove keys with empty map or nil values.

E.g., given {:a {:b {:c nil} :d {:e :f}}}, return {:a {:d {:e :f}}}."
[m]
(into {} (filter (fn [[_ v]] (not ((if (map? v) empty? nil?) v)))
(mapv (fn [[k v]] [k (if (map? v)
(remove-empty-keys v)
v)])
m))))

(defn dispatch!
"Dispatches to the given url.

Expand Down