A way better approach for form validation. Feels good now.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
[bidi "2.1.6"]
|
||||
[ring/ring-defaults "0.3.2" :exclusions [ring ring/ring-core]]
|
||||
[mount "0.1.16"]
|
||||
[metosin/malli "0.8.9"]
|
||||
[tolitius/yang "0.1.23"]
|
||||
[ring "1.8.2" :exclusions [commons-codec
|
||||
commons-io
|
||||
|
||||
6
resources/public/css/bulma.min.css
vendored
6
resources/public/css/bulma.min.css
vendored
@@ -10974,6 +10974,12 @@ span[data-tooltip].has-tooltip-primary-two {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.typeahead input[disabeld] {
|
||||
background-color: whitesmoke;
|
||||
border-color: whitesmoke;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.typeahead-suggestion {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -462,3 +462,10 @@ table.balance-sheet th.total {
|
||||
.modal-card-foot {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
.typeahead input[disabeld] {
|
||||
background-color: whitesmoke !important;
|
||||
border-color: whitesmoke !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
4
resources/sass/bulma.scss
vendored
4
resources/sass/bulma.scss
vendored
@@ -93,7 +93,7 @@ $fullhd-enabled: false;
|
||||
.modal-card {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
.typeahead-suggestion {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
@@ -164,3 +164,5 @@ tbody tr.live-added {
|
||||
.button.is-outlined {
|
||||
border-width: 2.5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,21 +2,4 @@
|
||||
(:require [clojure.spec.alpha :as s]
|
||||
[auto-ap.entities.shared :as shared]))
|
||||
|
||||
(s/def ::vendor map?)
|
||||
(s/def ::vendor-name string?)
|
||||
(s/def ::client map?)
|
||||
(s/def ::invoice-number ::shared/required-identifier)
|
||||
(s/def ::date ::shared/date)
|
||||
(s/def ::due (s/nilable ::shared/date))
|
||||
(s/def ::scheduled-payment (s/nilable ::shared/date))
|
||||
(s/def ::total ::shared/money)
|
||||
|
||||
(s/def ::invoice (s/keys :req-un [::client
|
||||
::invoice-number
|
||||
::date
|
||||
::vendor
|
||||
::total]
|
||||
:opt-un [::vendor-name
|
||||
::due
|
||||
::scheduled-payment
|
||||
]))
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
:ldt? #(instance? org.joda.time.LocalDate %)
|
||||
:str? (s/and string? #(re-matches date-regex %)))))
|
||||
|
||||
(s/def ::required some?)
|
||||
(s/def ::has-id (s/and map?
|
||||
#(:id %)))
|
||||
(s/def ::required-identifier (s/and string?
|
||||
#(not (str/blank? %))))
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
(re-frame/reg-sub
|
||||
::form
|
||||
(fn [db [_ x]]
|
||||
(get (-> db ::forms) x)))
|
||||
(update (get (-> db ::forms) x)
|
||||
:visited (fn [v]
|
||||
(or v #{})))))
|
||||
|
||||
(re-frame/reg-sub
|
||||
::field
|
||||
@@ -31,6 +33,7 @@
|
||||
(assoc-in db [::forms form] {:error nil
|
||||
:active? true
|
||||
:id (random-uuid)
|
||||
:visited #{}
|
||||
:status nil
|
||||
:data data
|
||||
:complete-listener complete-listener})))
|
||||
@@ -75,6 +78,12 @@
|
||||
db
|
||||
(partition 2 path-pairs))))
|
||||
|
||||
(re-frame/reg-event-db
|
||||
::visited
|
||||
(fn [db [_ form & paths]]
|
||||
(update-in db [::forms form :visited] (fn [v]
|
||||
(set (into v paths))))))
|
||||
|
||||
(defn change-handler [form customize-fn]
|
||||
(fn [db [_ & path-pairs]]
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
[react :as react]
|
||||
[reagent.core :as r]
|
||||
[auto-ap.forms :as forms]
|
||||
[auto-ap.status :as status]))
|
||||
[auto-ap.status :as status]
|
||||
[malli.core :as m]
|
||||
[malli.error :as me]))
|
||||
|
||||
(defonce ^js/React.Context form-context (react/createContext "default"))
|
||||
(def ^js/React.Provider Provider (. form-context -Provider))
|
||||
@@ -15,16 +17,46 @@
|
||||
(def ^js/React.Provider FormScopeProvider (. form-scope-context -Provider))
|
||||
(def ^js/React.Consumer FormScopeConsumer (. form-scope-context -Consumer))
|
||||
|
||||
(defn builder [{:keys [can-submit data-sub change-event submit-event id fullwidth?] :as z}]
|
||||
(let [data-sub (or data-sub [::forms/form id])
|
||||
change-event (or change-event [::forms/change id])
|
||||
{:keys [data error] form-key :id} @(re-frame/subscribe data-sub)
|
||||
status @(re-frame/subscribe [::status/single id])
|
||||
can-submit (if can-submit @(re-frame/subscribe can-submit)
|
||||
true)]
|
||||
(defn valid-field? [problems field-path]
|
||||
(not (get-in (me/humanize problems) field-path)))
|
||||
|
||||
(defn spec-error-message [problems field-path error-messages]
|
||||
(-> (me/humanize problems
|
||||
{:errors (merge (-> me/default-errors
|
||||
(assoc ::m/missing-key {:error/message "Required"}
|
||||
::m/invalid-type {:error/fn
|
||||
(fn [a b]
|
||||
(if (nil? (:value a))
|
||||
"Required"
|
||||
"Invalid"))}))
|
||||
error-messages)})
|
||||
(get-in field-path)
|
||||
first))
|
||||
|
||||
(defn builder [{:keys [value on-change can-submit data-sub error-messages change-event submit-event id fullwidth? schema] :as z}]
|
||||
(when (and change-event on-change)
|
||||
(throw "Either the form is to be managed by ::forms, or it should have value and on-change passed in"))
|
||||
(let [data-sub (or data-sub [::forms/form id])
|
||||
change-event (when-not on-change
|
||||
(or change-event [::forms/change id]))
|
||||
{:keys [data error visited] form-key :id} @(re-frame/subscribe data-sub)
|
||||
data (or value data)
|
||||
status @(re-frame/subscribe [::status/single id])
|
||||
can-submit (if can-submit @(re-frame/subscribe can-submit)
|
||||
true)
|
||||
problems (when schema
|
||||
(m/explain schema data))]
|
||||
|
||||
|
||||
(r/create-element Provider #js {:value #js {:can-submit can-submit
|
||||
:error-messages (or error-messages
|
||||
nil)
|
||||
:on-change on-change
|
||||
:change-event change-event
|
||||
:blur-event [::forms/visited id]
|
||||
:visited visited
|
||||
:submit-event submit-event
|
||||
:problems problems
|
||||
:error error
|
||||
:status status
|
||||
:id id
|
||||
@@ -42,6 +74,30 @@
|
||||
(r/children (r/current-component)))]
|
||||
))))
|
||||
|
||||
(defn virtual-builder []
|
||||
(let [key (r/atom (random-uuid))]
|
||||
(fn [{:keys [value on-change can-submit error-messages fullwidth? schema]}]
|
||||
(let [data-sub [::forms/form @key]
|
||||
{:keys [data error visited]} @(re-frame/subscribe data-sub)
|
||||
data (or value data)
|
||||
problems (when schema
|
||||
(m/explain schema data))]
|
||||
(r/create-element Provider #js {:value #js {:can-submit can-submit
|
||||
:error-messages (or error-messages
|
||||
nil)
|
||||
:on-change on-change
|
||||
:blur-event [::forms/visited @key]
|
||||
:visited visited
|
||||
:problems problems
|
||||
:error error
|
||||
:id @key
|
||||
:data data
|
||||
:fullwidth? fullwidth?}}
|
||||
(r/as-element
|
||||
^{:key @key}
|
||||
(into [:<>]
|
||||
(r/children (r/current-component)))))))))
|
||||
|
||||
(defn raw-field []
|
||||
(let [[child] (r/children (r/current-component))]
|
||||
[:> Consumer {}
|
||||
@@ -65,10 +121,91 @@
|
||||
(assoc-in [1 :subscription] (aget consume-form "data"))
|
||||
(assoc-in [1 :event] (aget consume-form "change-event")))]))]))]))
|
||||
|
||||
(defn change-handler [path re-frame-change-event event-or-value]
|
||||
(re-frame/dispatch (-> re-frame-change-event
|
||||
(conj path)
|
||||
(conj (if-let [target (some-> event-or-value (aget "target"))]
|
||||
(aget target "value")
|
||||
event-or-value)))))
|
||||
|
||||
(defn form-change-handler [data path on-change event-or-value]
|
||||
(on-change (assoc-in data path (if-let [target (some-> event-or-value (aget "target"))]
|
||||
(aget target "value")
|
||||
event-or-value))
|
||||
data))
|
||||
|
||||
(defn blur-handler [path re-frame-blur-event _]
|
||||
(re-frame/dispatch (-> re-frame-blur-event
|
||||
(conj path))))
|
||||
|
||||
(defn raw-error-v2 [{:keys [field]}]
|
||||
[:> Consumer {}
|
||||
(fn [consume-form]
|
||||
(r/as-element
|
||||
[:> FormScopeConsumer {}
|
||||
(fn [form-scope]
|
||||
(r/as-element
|
||||
(let [full-field-path (cond
|
||||
(sequential? field)
|
||||
(into form-scope field)
|
||||
|
||||
field
|
||||
(conj form-scope field)
|
||||
|
||||
:else
|
||||
nil)
|
||||
visited? (get (aget consume-form "visited") full-field-path)]
|
||||
(when-let [error-message (and
|
||||
visited?
|
||||
(spec-error-message (aget consume-form "problems") full-field-path (aget consume-form "error-messages")))]
|
||||
[:div
|
||||
[:p.help.has-text-danger error-message]]))))]))])
|
||||
|
||||
(defn raw-field-v2 [{:keys [field]}]
|
||||
(let [[child] (r/children (r/current-component))]
|
||||
[:> Consumer {}
|
||||
(fn [consume-form]
|
||||
(r/as-element
|
||||
[:> FormScopeConsumer {}
|
||||
(fn [form-scope]
|
||||
(r/as-element
|
||||
(update child 1 (fn [child-props]
|
||||
(let [
|
||||
full-field-path (cond
|
||||
(sequential? field)
|
||||
(into form-scope field)
|
||||
|
||||
field
|
||||
(conj form-scope field)
|
||||
|
||||
:else
|
||||
nil)
|
||||
visited? (get (aget consume-form "visited") full-field-path)
|
||||
value (get-in (aget consume-form "data") full-field-path)
|
||||
on-change (aget consume-form "on-change")]
|
||||
(-> child-props
|
||||
(assoc :on-change
|
||||
(if on-change
|
||||
(partial form-change-handler (aget consume-form "data") full-field-path (aget consume-form "on-change"))
|
||||
(partial change-handler full-field-path (aget consume-form "change-event")))
|
||||
|
||||
:on-blur (partial blur-handler full-field-path (aget consume-form "blur-event"))
|
||||
:value value)
|
||||
(update :class (fn [class]
|
||||
(str class
|
||||
(cond
|
||||
(not visited?)
|
||||
""
|
||||
(not (valid-field? (aget consume-form "problems") full-field-path))
|
||||
" is-danger"
|
||||
:else
|
||||
"is-success"))))))))))]))]))
|
||||
|
||||
(defn with-scope [{:keys [scope]}]
|
||||
(r/create-element FormScopeProvider #js {:value scope}
|
||||
(r/as-element (into [:<>]
|
||||
(r/children (r/current-component))))))
|
||||
|
||||
(defn vertical-control [{:keys [is-small? required?]}]
|
||||
(let [[label & children] (r/children (r/current-component))]
|
||||
[:> Consumer {}
|
||||
@@ -99,6 +236,24 @@
|
||||
label)]))
|
||||
[:div.control [raw-field {} child]]]))]))
|
||||
|
||||
(defn field-v2 []
|
||||
(let [props (r/props (r/current-component))
|
||||
[label child] (r/children (r/current-component))]
|
||||
[:> Consumer {}
|
||||
(fn [consume]
|
||||
(r/as-element
|
||||
[:div.field
|
||||
(when label
|
||||
(if (aget consume "fullwidth?")
|
||||
[:p.help label]
|
||||
[:label.label
|
||||
(if (:required? props)
|
||||
[:span label [:span.has-text-danger " *"]]
|
||||
label)]))
|
||||
[:div.control [raw-field-v2 props child]]
|
||||
[:div
|
||||
[raw-error-v2 {:field (:field props)}]]]))]))
|
||||
|
||||
(defn horizontal-control []
|
||||
(let [[label & children] (r/children (r/current-component))]
|
||||
[:div.field.is-horizontal
|
||||
|
||||
19
src/cljs/auto_ap/schema.cljs
Normal file
19
src/cljs/auto_ap/schema.cljs
Normal file
@@ -0,0 +1,19 @@
|
||||
(ns auto-ap.schema
|
||||
(:require [malli.core :as m]))
|
||||
|
||||
(def reference (m/schema [:map [:id :string]]))
|
||||
(def date (m/schema [:fn
|
||||
(fn [d]
|
||||
(if-not (or (instance? goog.date.DateTime d)
|
||||
(instance? goog.date.Date d))
|
||||
(throw (ex-info "Invalid Date" {:type ::m/invalid-type}))
|
||||
true))]))
|
||||
|
||||
(def money (m/schema [float? {:error/message "Invalid money"}]))
|
||||
(def not-empty-string (m/schema [:re {:error/message "Required"} #"\S+"]))
|
||||
|
||||
(def expense-account (m/schema [:map
|
||||
[:id :string]
|
||||
[:account reference]
|
||||
[:location :string]
|
||||
[:amount money]]))
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
]]))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user