diff --git a/clinic/shadow-cljs.edn b/clinic/shadow-cljs.edn index d3187f4..d68066c 100644 --- a/clinic/shadow-cljs.edn +++ b/clinic/shadow-cljs.edn @@ -1,5 +1,6 @@ {:source-paths ["src/cljc" "src/cljs"] :dependencies [[bidi "2.1.6"] + [binaryage/devtools "1.0.7"] [cljs-ajax "0.7.5"] [day8.re-frame/http-fx "0.2.4"] [kibu/pushy "0.3.8"] diff --git a/clinic/src/cljs/clinic/components.cljs b/clinic/src/cljs/clinic/components.cljs index b305faf..599194d 100644 --- a/clinic/src/cljs/clinic/components.cljs +++ b/clinic/src/cljs/clinic/components.cljs @@ -28,31 +28,30 @@ "Logout"]])]] (r/children this)))) -(defn text-field [_ _ _ _ validation-fn] - (let [valid? (r/atom true)] - (fn [name label placeholder error-msg _] - [:div {:class ["w-full" "flex" "flex-col" "gap-2"]} - [:label {:for name - :class ["block" "uppercase" "tracking-wide" "text-gray-600" - "text-xs" "font-bold"]} - label] +(defn text-field [] + (let [{name :name + label :label + placeholder :placeholder + error-msg :error-msg + touched? :touched? + invalid? :invalid?} (r/props (r/current-component))] + [:div {:class ["w-full" "flex" "flex-col" "gap-2"]} + [:label {:for name + :class ["block" "uppercase" "tracking-wide" "text-gray-600" + "text-xs" "font-bold"]} + label] - [:input {:id name - :name name - :placeholder placeholder - :class ["appearance-none" "block" "w-full" "bg-gray-200" - "text-gray-700" "border" "border-gray-200" - "rounded" "py-3" "px-4" "leading-tight" - "focus:outline-none" "focus:bg-white" - "focus:border-gray-500"] - :on-change #(->> % - (.-target) - (.-value) - (validation-fn) - (reset! valid?))}] - [:p {:class [(if @valid? "invisible" "visible") - "text-red-500" "text-xs" "italic"]} - error-msg]]))) + [:input {:id name + :name name + :placeholder placeholder + :class ["appearance-none" "block" "w-full" "bg-gray-200" + "text-gray-700" "border" "border-gray-200" + "rounded" "py-3" "px-4" "leading-tight" + "focus:outline-none" "focus:bg-white" + "focus:border-gray-500"]}] + [:p {:class [(if (and touched? invalid?) "visible" "invisible") + "text-red-500" "text-xs" "italic"]} + error-msg]])) (defn select-field [name label default-value options] [:div {:class ["w-full" "flex" "flex-col" "gap-2"]} diff --git a/clinic/src/cljs/clinic/utils.cljs b/clinic/src/cljs/clinic/utils.cljs index a5bc462..a5a289f 100644 --- a/clinic/src/cljs/clinic/utils.cljs +++ b/clinic/src/cljs/clinic/utils.cljs @@ -1,11 +1,25 @@ -(ns clinic.utils) +(ns clinic.utils + (:require [clojure.spec.alpha :as s])) (defn form-data->map - "Converts DOM FormData to a Clojure map. Also keywordizes keys and filters - out empty values from the resulting map." - [form-data] + "Converts DOM FormData to a Clojure map. Also keywordizes keys in the + resulting map and then removes `optional-keys` from it if the corresponding + value is empty." + [form-data optional-keyset] (->> form-data (.entries) (map vec) - (remove #(empty? (second %))) ; remove empty fields + (remove #(and (contains? optional-keyset (keyword (first %))) + (empty? (second %)))) ; remove empty fields that are optional (reduce #(assoc %1 (keyword (first %2)) (second %2)) {}))) + +(defn invalid-keys + "Returns a set of keys whose values don't conform to the given `spec` for the + given `data`." + [spec data] + (->> data + (s/explain-data spec) + (::s/problems) + (map :in) + (flatten) + (set))) diff --git a/clinic/src/cljs/clinic/views/create_patient.cljs b/clinic/src/cljs/clinic/views/create_patient.cljs index a46e1fa..6469a3d 100644 --- a/clinic/src/cljs/clinic/views/create_patient.cljs +++ b/clinic/src/cljs/clinic/views/create_patient.cljs @@ -4,7 +4,6 @@ [clinic.router :as router] [clinic.specs.patient :as specs] [clinic.utils :as u] - [clojure.spec.alpha :as s] [day8.re-frame.http-fx] [re-frame.core :as rf] [reagent.core :as r])) @@ -33,26 +32,47 @@ (rf/reg-sub ::submitting-form :-> ::submitting-form) (rf/reg-sub ::submit-form-error-code :-> ::submit-form-error-code) +(defn form-data [form] + (-> form + (js/FormData.) + (u/form-data->map #{:marital-status :email :phone}))) + +(defn find-invalid-keys [form] + (->> form + (form-data) + (u/invalid-keys ::specs/create-params))) + (defn root [] - (let [form-valid? (r/atom nil) + (let [form-ref (atom nil) + touched? (r/atom #{}) + invalid? (r/atom #{}) submitting? (rf/subscribe [::submitting-form]) submit-error-code (rf/subscribe [::submit-form-error-code])] (fn [] + (prn @touched?) + (prn @invalid?) [:section {:class ["flex" "flex-col" "gap-12"]} [components/heading-2 "Add a Patient"] - [:form {:method "POST" + [:form {:ref (partial reset! form-ref) + :method "POST" :action "/api/v1/patients/" :class ["w-full" "flex" "flex-col" "gap-4"] + :on-blur #(do (swap! touched? conj (-> % + (.-target) + (.-id) + (keyword))) + (reset! invalid? (find-invalid-keys @form-ref))) + :on-change #(do (swap! touched? conj (-> % + (.-target) + (.-id) + (keyword))) + (reset! invalid? (find-invalid-keys @form-ref))) :on-submit #(do (.preventDefault %) - (let [form-data (-> % - (.-target) - (js/FormData.) - (u/form-data->map))] - (->> form-data - (s/valid? ::specs/create-params) - (reset! form-valid?)) - - (when @form-valid? + (let [form-data (form-data @form-ref)] + ;; touch all fields and revalidate data. + (reset! touched? (set (keys form-data))) + (reset! invalid? (find-invalid-keys @form-ref)) + (when (empty? @invalid?) (rf/dispatch [::submit-form form-data]))))} (when @submit-error-code @@ -62,31 +82,28 @@ correct?" "There was an error while adding patient. Please try again!")]) - (when (false? @form-valid?) - [components/danger-alert "Missing required fields or invalid input!"]) - [:div {:class ["w-full" "flex" "flex-col" "md:flex-row" "gap-8" "md:gap-12"]} - [components/text-field - :first-name - "First Name *" - "Jane" - "Please enter a valid first name!" - (partial s/valid? ::specs/first-name)] - - [components/text-field - :last-name - "Last Name *" - "Doe" - "Please enter a valid last name!" - (partial s/valid? ::specs/last-name)]] + [components/text-field {:name :first-name + :label "First Name *" + :placeholder "Jane" + :error-msg "Please enter a valid first name!" + :touched? (contains? @touched? :first-name) + :invalid? (contains? @invalid? :first-name)}] + + [components/text-field {:name :last-name + :label "Last Name *" + :placeholder "Doe" + :error-msg "Please enter a valid last name!" + :touched? (contains? @touched? :last-name) + :invalid? (contains? @invalid? :last-name)}]] [:div {:class ["w-full" "flex" "flex-col" "md:flex-row" "gap-8" "md:gap-12"]} - [components/text-field - :birth-date - "Date of Birth *" - "1999-12-30" - "Please enter a valid date of birth in YYYY-MM-DD format!" - (partial s/valid? ::specs/birth-date)] + [components/text-field {:name :birth-date + :label "Date of Birth *" + :placeholder "1999-12-30" + :error-msg "Please enter a valid date of birth in YYYY-MM-DD format!" + :touched? (contains? @touched? :birth-date) + :invalid? (contains? @invalid? :birth-date)}] [components/select-field :gender @@ -107,18 +124,18 @@ ["Widowed" "W"] ["Unknown" "UNK"]]] - [components/text-field - :email - "Email" - "jane@doe.org" - "Please enter a valid email!" - (partial s/valid? ::specs/email)] - - [components/text-field - :phone - "Phone" - "0000-000-000" - "Please enter a valid phone!" - (partial s/valid? ::specs/phone)] + [components/text-field {:name :email + :label "Email" + :placeholder "jane@doe.org" + :error-msg "Please enter a valid email!" + :touched? (contains? @touched? :email) + :invalid? (contains? @invalid? :email)}] + + [components/text-field {:name :phone + :label "Phone" + :placeholder "0000-000-000" + :error-msg "Please enter a valid phone!" + :touched? (contains? @touched? :phone) + :invalid? (contains? @invalid? :phone)}] [components/button "submit" "Add Patient" @submitting?]]])))