A way better approach for form validation. Feels good now.
This commit is contained in:
@@ -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]))
|
||||
|
||||
|
||||
@@ -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"]])]])
|
||||
|
||||
@@ -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"
|
||||
|
||||
74
src/cljs/auto_ap/views/components/percentage_field.cljs
Normal file
74
src/cljs/auto_ap/views/components/percentage_field.cljs
Normal 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))])
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user