A way better approach for form validation. Feels good now.

This commit is contained in:
2022-07-19 08:24:33 -07:00
parent b84600e4f1
commit cab3a84903
18 changed files with 530 additions and 111 deletions

View File

@@ -1,8 +1,5 @@
(ns auto-ap.views.components.bank-account-filter
(:require
[clojure.spec.alpha :as s]
[auto-ap.entities.invoice :as invoice]
[auto-ap.views.utils :refer [bind-field ->$]]
[auto-ap.subs :as subs]
[re-frame.core :as re-frame]))

View File

@@ -1,9 +1,18 @@
(ns auto-ap.views.components.expense-accounts-field
(:require
[auto-ap.views.utils :refer [->$ bind-field dispatch-event]]
[auto-ap.views.components.typeahead.vendor :refer [search-backed-typeahead]]
[auto-ap.forms.builder :as form-builder]
[auto-ap.schema :as schema]
[auto-ap.utils :refer [dollars-0?]]
[auto-ap.views.components.button-radio :as button-radio]
[auto-ap.views.components.level :refer [left-stack]]
[auto-ap.views.components.money-field :refer [money-field]]
[auto-ap.views.components.percentage-field :refer [percentage-field]]
[auto-ap.views.components.typeahead.vendor
:refer [search-backed-typeahead]]
[auto-ap.views.utils :refer [->$ bind-field dispatch-event appearing-group]]
[clojure.string :as str]
[goog.string :as gstring]
[malli.core :as m]
[re-frame.core :as re-frame]))
(defn can-replace-with-default? [accounts]
@@ -113,7 +122,7 @@
[:div.column
[:h1.subtitle.is-4.is-inline (str/capitalize descriptor) "s"]
(when-not percentage-only?
[:p.help "Remaining " (->$ (- max-value (reduce + 0 (map (comp js/parseFloat :amount) expense-accounts))))])]
[:p.help "Remaining" (->$ (- max-value (reduce + 0 (map (comp js/parseFloat :amount) expense-accounts))))])]
[:div.column.is-narrow
(when-not disabled
[:p.buttons
@@ -216,3 +225,122 @@
:value (get-in expense-account [:amount-percentage])
:max "100"
:step "0.01"}]])]]]]])])
(def schema (m/schema [:sequential [:map
[:id :string]
[:account schema/reference]
[:location schema/not-empty-string]
[:amount schema/money]]]))
(defn select-field [{:keys [options allow-nil? class] :as props}]
[:div.select {:class class}
[:select (-> props
(dissoc :allow-nil? :class :options)
(update :value (fn [v] (if (str/blank? v)
""
v))))
[:<>
(when allow-nil?
[:option {:value nil}])
(for [[k v] options]
^{:key k} [:option {:value k} v])]]])
(defn expense-accounts-field-v2 [{value :value on-change :on-change expense-accounts :value client :client max-value :max locations :locations disabled :disabled percentage-only? :percentage-only? :or {percentage-only? false}}]
[form-builder/virtual-builder {:value value
:schema schema
:on-change (fn [expense-accounts original-expense-accounts]
(let [updated-expense-accounts
(for [[before-account after-account] (map vector original-expense-accounts expense-accounts)]
(cond-> after-account
(not= (:id (:account before-account))
(:id (:account after-account)))
(assoc :location nil)
(not= (:amount-percentage before-account)
(:amount-percentage after-account))
(assoc :amount (* (/ (:amount-percentage after-account) 100.0)
max-value))
(:location (:account after-account))
(assoc :location (:location (:account after-account)))))]
(on-change (into [] updated-expense-accounts))))}
[:div
[:div.tags
(when max-value
[:div.tag "To Allocate: " (->$ max-value)])
(when-not percentage-only?
(let [total (reduce + 0 (map (or :amount 0.0) expense-accounts))]
[:<>
[:div.tag "Total: " (->$ total) ]
[:div.tag {:class (if (dollars-0? (- max-value total))
["is-primary" "is-light"]
["is-danger" "is-light"])}
"Remaining: " (->$ (- max-value total))]]))]
(into [appearing-group]
(for [[index {:keys [account id amount amount-mode]}] (map vector (range) expense-accounts)]
^{:key id}
[:div.card {:style {:margin-bottom "2em"}}
[:div.card-header
[:p.card-header-title "Expense Account"]
(when-not disabled
[:div.card-header-icon {:on-click (fn []
(on-change (into [] (filter #(not= id (:id %)) expense-accounts))))}
[:a.delete ]])]
[:div.card-content
[:div.field
[:div.columns
[:div.column
[:div.control.is-fullwidth
[form-builder/field-v2 {:required? true
:field [index :account]}
"Account"
[search-backed-typeahead {:search-query (fn [i]
[:search_account
{:query i
:client-id (:id client)}
[:name :id :location]])
:disabled disabled}]]]]
[:div.column.is-narrow
[form-builder/field-v2 {:required? true
:field [index :location]}
"Location"
[select-field {:options (if (:location account)
[[(:location account) (:location account)]]
(map (fn [l] [l l])
locations))
:disabled (boolean (:location account))
:allow-nil? true}]]]]]
[left-stack
[:div.field.has-addons
[form-builder/raw-field-v2 {:field [index :amount-mode]}
[button-radio/button-radio {:options [["$" "Amount"]
["%" "Percent"]]}]]
(if (= "$" amount-mode)
[form-builder/raw-field-v2 {:field [index :amount]}
[money-field {}]
]
[form-builder/raw-field-v2 {:field [index :amount-percentage]}
[percentage-field {}]])]
(when (= "%" amount-mode)
[:div.tag.is-primary.is-light (gstring/format "$%.2f" (or amount 0) )])]]]))
(when-not disabled
[:p.buttons
[:a.button {:on-click (fn []
(on-change
(recalculate-amounts (mapv
(fn [ea]
(assoc ea :amount-percentage (* 100.0 (/ 1 (count expense-accounts)))))
expense-accounts)
max-value))
)} "Spread evenly"]
[:a.button {:on-click
(fn []
(on-change (conj value {:id (str "new-" (random-uuid))
:amount-mode "%"
:location (if (= 1 (count locations))
(first locations)
nil)})))}
"Add"]])]])

View File

@@ -5,7 +5,7 @@
[react :as react]))
(def good-$ #"^\-?[0-9]+(\.[0-9][0-9])?$")
(defn -money-field [{:keys [min max disabled on-change value class style placeholder]}]
(defn -money-field [{:keys [min max disabled on-blur on-change value class style placeholder]}]
(let [[ parsed-amount set-parsed-amount] (react/useState {:parsed value
:raw (cond
(str/blank? value)
@@ -59,7 +59,9 @@
(set-parsed-amount {:raw ""
:parsed nil})
(on-change nil)))
(on-change nil))
(when on-blur
(on-blur)))
:min min
:max max
:step "0.01"

View File

@@ -0,0 +1,74 @@
(ns auto-ap.views.components.percentage-field
(:require [reagent.core :as r]
[auto-ap.views.utils :refer [->short$]]
[clojure.string :as str]
[react :as react]))
(def good-% #"^\d{1,3}$")
(defn -percentage-field [{:keys [min max disabled on-blur on-change value class style placeholder]}]
(let [[ parsed-amount set-parsed-amount] (react/useState {:parsed value
:raw (cond
(str/blank? value)
""
(js/Number.isNaN (js/parseInt value))
""
:else
(str (js/parseInt value)))})]
(react/useEffect (fn []
;; allow the controlling field to change the raw representation
;; when the raw amount is a valid representation, so that 33.
;; doesn't get unset
(when (or
(and (:raw parsed-amount)
(re-find good-% (:raw parsed-amount)))
(str/blank? (:raw parsed-amount)))
(set-parsed-amount
(assoc parsed-amount
:parsed value
:raw (cond
(str/blank? value)
""
(js/Number.isNaN (js/parseInt value))
""
:else
(str (js/parseInt value))))))
nil))
[:div.control.has-icons-left
[:input.input {:type "text"
:disabled disabled
:placeholder placeholder
:class class
:on-change (fn [e]
(let [raw (.. e -target -value)
new-value (when (and raw
(not (str/blank? raw))
(re-find good-% raw))
(js/parseFloat raw))]
(set-parsed-amount {:raw raw
:parsed new-value})
(when (not= value new-value)
(on-change new-value))))
:value (or (:raw parsed-amount)
"")
:on-blur (fn []
(when-not (re-find good-% (:raw parsed-amount))
(set-parsed-amount {:raw ""
:parsed nil})
(on-change nil))
(when on-blur
(on-blur)))
:min min
:max max
:step "0.01"
:style (or style
{:width "8em"})}]
[:span.icon.is-left
[:i.fa.fa-percent]]]))
(defn percentage-field []
[:f> -percentage-field (r/props (r/current-component))])

View File

@@ -83,7 +83,7 @@
:time 250
:key ::input-value-settled}})))
(defn typeahead-v3-internal [{:keys [class entity->text entities on-input-change style ^js on-change disabled value name auto-focus] :or {disabled false}
(defn typeahead-v3-internal [{:keys [class entity->text entities on-input-change style ^js on-change disabled value name auto-focus on-blur] :or {disabled false}
prop-value :value}]
(let [[items set-items] (react/useState (or entities
[]))
@@ -134,7 +134,8 @@
focused
(conj "is-focused")
)}
)
}
(when selectedItem
^{:key "hidden"}
[:div.level-item
@@ -165,22 +166,25 @@
:disabled disabled
:onFocus #(set-focus true)
:onBlur #(set-focus false)
:onBlur #(do (set-focus false)
(when on-blur
(on-blur)))
:autoFocus (if auto-focus
"autoFocus"
"")}))]]]
[popper {:class (when (and isOpen (seq items))
"typeahead-menu")}
[:ul (js->clj (getMenuProps))
(when (and isOpen (seq items))
(for [[index item] (map vector (range) (js->clj items :keywordize-keys true))]
^{:key item}
[:li.typeahead-suggestion (assoc (js->clj (getItemProps #js {:item item :index index}))
:class (when (= index highlightedIndex)
"typeahead-highlighted"))
(if entity->text
(entity->text item)
(:name item))]))]]]]))
[:div (js->clj (getMenuProps))
(when (and isOpen (seq items))
[popper {:class "typeahead-menu"}
[:ul
(when (and isOpen (seq items))
(for [[index item] (map vector (range) (js->clj items :keywordize-keys true))]
^{:key item}
[:li.typeahead-suggestion (assoc (js->clj (getItemProps #js {:item item :index index}))
:class (when (= index highlightedIndex)
"typeahead-highlighted"))
(if entity->text
(entity->text item)
(:name item))]))]])]]]))
(defn search-backed-typeahead [{:keys [search-query] :as props}]
[:div

View File

@@ -1,7 +1,3 @@
(ns auto-ap.views.components.vendor-filter
(:require
[clojure.spec.alpha :as s]
[auto-ap.entities.invoice :as invoice]
[auto-ap.views.utils :refer [bind-field]]))
(ns auto-ap.views.components.vendor-filter)

View File

@@ -1,6 +1,5 @@
(ns auto-ap.views.pages.invoices.form
(:require
[auto-ap.entities.invoice :as invoice]
[auto-ap.events :as events]
[auto-ap.forms :as forms]
[auto-ap.forms.builder :as form-builder]
@@ -8,10 +7,11 @@
[auto-ap.subs :as subs]
[auto-ap.time-utils :refer [next-dom]]
[auto-ap.utils :refer [dollars=]]
[auto-ap.schema :as schema]
[auto-ap.views.components.expense-accounts-field
:as eaf
:refer [recalculate-amounts
expense-accounts-field]]
expense-accounts-field-v2]]
[auto-ap.views.components.layouts :as layouts]
[auto-ap.views.components.level :refer [left-stack]]
[auto-ap.views.components.modal :as modal]
@@ -20,6 +20,7 @@
[auto-ap.views.components.switch-field :refer [switch-field]]
[auto-ap.views.components.typeahead :refer [typeahead-v3]]
[auto-ap.views.components.typeahead.vendor
:refer [search-backed-typeahead]]
[auto-ap.views.pages.invoices.common :refer [invoice-read]]
[auto-ap.views.utils
@@ -27,13 +28,25 @@
dispatch-event
with-user]]
[cljs-time.core :as c]
[clojure.spec.alpha :as s]
[clojure.string :as str]
[re-frame.core :as re-frame]
[reagent.core :as r]
[malli.core :as m]
[vimsical.re-frame.cofx.inject :as inject]
[vimsical.re-frame.fx.track :as track]))
(def schema (m/schema
[:map
[:client schema/reference]
[:vendor schema/reference]
[:date schema/date]
[:due {:optional true} [:maybe schema/date]]
[:scheduled-payment {:optional true} [:maybe schema/date]]
[:invoice-number schema/not-empty-string]
[:total schema/money]
[:expense-accounts eaf/schema]]))
;; SUBS
(re-frame/reg-sub
::can-submit
@@ -42,11 +55,12 @@
(let [min-total (if (= (:total (:original data)) (:outstanding-balance (:original data)))
nil
(- (:total (:original data)) (:outstanding-balance (:original data))))
account-total (reduce + 0 (map (fn [ea] (js/parseFloat (:amount ea))) (:expense-accounts data)))]
(and (s/valid? ::invoice/invoice data)
(or (not min-total) (>= (:total data) min-total))
(or (not (:id data))
(dollars= (Math/abs (js/parseFloat (:total data))) (Math/abs account-total)))))))
account-total (reduce + 0 (map :amount (:expense-accounts data)))]
(and
(m/validate schema data)
(or (not min-total) (>= (:total data) min-total))
(or (not (:id data))
(dollars= (Math/abs (:total data)) (Math/abs account-total)))))))
(re-frame/reg-sub
::create-query
@@ -143,8 +157,8 @@
:vendor (:vendor edit-invoice)
:client (:client edit-invoice)
:expense-accounts (eaf/from-graphql (:expense-accounts which)
(:total which)
locations-for-client)}))})))
(:total which)
locations-for-client)}))})))
@@ -321,7 +335,8 @@
[form-builder/builder {:can-submit [::can-submit]
:change-event [::changed]
:submit-event [::save-requested [::saving ]]
:id ::form}
:id ::form
:schema schema}
[form-builder/section {:title [:div "New Invoice "
(cond
@@ -344,48 +359,39 @@
nil)]}
(when-not active-client
[form-builder/field {:required? true}
[form-builder/field-v2 {:required? true
:field [:client]}
"Client"
[typeahead-v3 {:entities @(re-frame/subscribe [::subs/clients])
:entity->text :name
:type "typeahead-v3"
:style {:width "8em"}
:style {:width "18em"}
:auto-focus (if active-client false true)
:field [:client]
:disabled exists?}]])
[form-builder/field {:required? true}
[form-builder/field-v2 {:required? true
:field [:vendor]}
"Vendor"
[search-backed-typeahead {:disabled exists?
:search-query (fn [i]
[:search_vendor
{:query i}
[:name :id]])
:type "typeahead-v3"
:style {:width "18em"}
:auto-focus (if active-client true false)
:field [:vendor]}]]
[form-builder/vertical-control {:required? true}
:auto-focus (if active-client true false)}]]
[form-builder/field-v2 {:required? true
:field :date}
"Date"
[:label
[form-builder/raw-field
[date-picker {:type "date"
:field [:date]
:output :cljs-date}]]]]
[date-picker {:output :cljs-date}]]
[form-builder/field
[form-builder/field-v2 {:field [:due]}
"Due (optional)"
[date-picker {:type "date"
:field [:due]
:output :cljs-date}]]
[date-picker {:output :cljs-date}]]
[form-builder/vertical-control
"Scheduled payment (optional)"
[left-stack
[:div.control
[form-builder/raw-field
[date-picker {:type "date"
:field [:scheduled-payment]
:output :cljs-date}]]]
[form-builder/raw-field-v2 {:field :scheduled-payment}
[date-picker {:output :cljs-date}]]
[form-builder/raw-error-v2 {:field :scheduled-payment}]]
[:div.control
[form-builder/raw-field
@@ -393,26 +399,23 @@
:field [:schedule-when-due]
:label "Same as due date"
:type "checkbox"}]]]]]
[form-builder/field {:required? true}
[form-builder/field-v2 {:required? true
:field :invoice-number}
"Invoice #"
[:input.input {:type "text"
:field [:invoice-number]
:style {:width "12em"}}]]
[form-builder/field {:required? true}
[:input.input {:style {:width "12em"}}]]
[form-builder/field-v2 {:required? true
:field :total}
"Total"
[money-field {:type "money"
:field [:total]
:disabled (if can-change-amount? "" "disabled")
[money-field {:disabled (if can-change-amount? "" "disabled")
:style {:max-width "8em"}
:min min-total
:step "0.01"}]]]
[form-builder/raw-field
[expense-accounts-field {:type "expense-accounts"
:descriptor "expense account"
:locations (:locations (:client data))
:max (:total data)
:client (or (:client data) active-client)
:field [:expense-accounts]}]]
:min min-total}]]]
[form-builder/field-v2 {:field :expense-accounts}
"Expense Accounts"
[expense-accounts-field-v2 {:descriptor "expense account"
:locations (:locations (:client data))
:max (:total data)
:client (or (:client data) active-client)}]]
[form-builder/error-notification]
[:div {:style {:margin-bottom "1em"}}]
[:div.columns

View File

@@ -150,12 +150,40 @@
(first children)
[:span])])))
(defn appearing-group []
(let [children (r/children (r/current-component))]
(into [transition-group {:exit true
:enter true}
(for [child children]
^{:key (:key (meta child))}
[transition
{:timeout 300
:exit true
:in true #_ (= current-stack- (:key (meta child)))}
(clj->js (fn [state]
(r/as-element
[:div {:style {
:transition "opacity 300ms ease-in-out"
:opacity (cond
(= "entered" state)
1.0
(= "entering" state)
0.0
(= "exiting" state)
0.0
(= "exited" state)
0.0)}}
child])))])])))
(defn multi-field [{:keys [value]} ]
(let [value-repr (reagent/atom (mapv
(fn [x]
(assoc x :key (random-uuid) :new? false))
value))]
(fn [x]
(assoc x :key (random-uuid) :new? false))
value))]
(fn [{:keys [template on-change allow-change? disable-new? disable-remove?]} ]
(let [value @value-repr
already-has-new-row? (= [:key :new?] (keys (last value)))
@@ -489,7 +517,9 @@
(swap-external-value (some-> (.. e -target -value) coerce-date))))
:on-blur (fn []
(swap-external-value (some-> text coerce-date)))
(swap-external-value (some-> text coerce-date))
(when (:on-blur params)
((:on-blur params))))
:type "date" :placeholder "12/1/2021")]
]]))