diff --git a/dev.cljs.edn b/dev.cljs.edn index 1ab2394f..839d1ef3 100644 --- a/dev.cljs.edn +++ b/dev.cljs.edn @@ -15,5 +15,4 @@ "--output-filename" :final-output-filename] :default ["npx" "webpack" "--mode=production" :output-to "--output-path" :final-output-dir - "--output-filename" :final-output-filename]} - } + "--output-filename" :final-output-filename]}} diff --git a/src/clj/auto_ap/datomic/migrate/vendors.clj b/src/clj/auto_ap/datomic/migrate/vendors.clj index 946b7dc6..49a0e45e 100644 --- a/src/clj/auto_ap/datomic/migrate/vendors.clj +++ b/src/clj/auto_ap/datomic/migrate/vendors.clj @@ -1,6 +1,14 @@ (ns auto-ap.datomic.migrate.vendors - (:require [datomic.api :as d] - [auto-ap.datomic :refer [uri]])) + (:require [datomic.api :as d])) + +(defn add-vendor-search-terms [conn] + [(->> (d/q '[:find ?i ?n + :in $ + :where [?i :vendor/name ?n]] + (d/db conn)) + (map (fn [[i n]] + {:db/id i + :vendor/search-terms n})))]) (def norms-map {:add-1099-stuff {:txes [[{:db/ident :vendor/legal-entity-first-name :db/doc "The first name for the legal entity" @@ -56,7 +64,15 @@ :db/valueType :db.type/long :db/cardinality :db.cardinality/one :db/noHistory true}] - ]}}) + ]} + ::make-fulltext-search {:txes [[{:db/ident :vendor/search-terms + :db/valueType :db.type/string + :db/cardinality :db.cardinality/many + :db/doc "a name search for vendors" + :db/fulltext true}]] + :requires [:auto-ap/base-schema]} + ::add-vendor-search-terms {:txes-fn `add-vendor-search-terms + :requires [::make-fulltext-search]}}) diff --git a/src/clj/auto_ap/datomic/vendors.clj b/src/clj/auto_ap/datomic/vendors.clj index 7c094fba..09899c38 100644 --- a/src/clj/auto_ap/datomic/vendors.clj +++ b/src/clj/auto_ap/datomic/vendors.clj @@ -85,6 +85,18 @@ (map #(trim-usage % (limited-clients (:id args)))) #_(map #(assoc % :usage (get usages (:db/id %)))))) +(defn get-graphql-by-id [args id] + (->> (cond-> {:query {:find [(list 'pull '?e default-read)] + :in ['$ '?e] + :where ['[?e :vendor/name]]} + :args [(d/db (d/connect uri)) id]}) + (d/query) + (map first) + (map #(cleanse (:id args) %)) + (map <-datomic) + (map #(trim-usage % (limited-clients (:id args)))) + first)) + (defn get-by-id [id] (->> (d/q '[:find (pull ?e [* diff --git a/src/clj/auto_ap/graphql.clj b/src/clj/auto_ap/graphql.clj index c96a40af..d1d35290 100644 --- a/src/clj/auto_ap/graphql.clj +++ b/src/clj/auto_ap/graphql.clj @@ -98,6 +98,10 @@ :message {:fields {:message {:type 'String}}} + :search_result + {:fields {:name {:type 'String} + :id {:type :id}}} + :yodlee_provider_account {:fields {:id {:type 'Int} :client {:type :client} @@ -347,6 +351,10 @@ :args {:account_set {:type 'String}} :resolve :get-accounts} + :search_vendor {:type '(list :search_result) + :args {:query {:type 'String}} + :resolve :search-vendor} + :all_sales_orders {:type '(list :sales_order) @@ -393,7 +401,10 @@ :vendor {:type '(list :vendor) :resolve :get-vendor} :user {:type '(list :user) - :resolve :get-user}} + :resolve :get-user} + :vendor_by_id {:type :vendor + :args {:id {:type :id}} + :resolve :vendor-by-id}} :input-objects { @@ -777,6 +788,7 @@ :get-cash-flow get-cash-flow :get-yodlee-merchants ym/get-yodlee-merchants :get-intuit-bank-accounts gq-intuit-bank-accounts/get-intuit-bank-accounts + :vendor-by-id gq-vendors/get-by-id :get-user get-user :mutation/delete-transaction-rule gq-transaction-rules/delete-transaction-rule :mutation/edit-user gq-users/edit-user @@ -787,7 +799,8 @@ :mutation/upsert-account gq-accounts/upsert-account :mutation/merge-vendors gq-vendors/merge-vendors :mutation/request-import gq-requests/request-import - :get-vendor gq-vendors/get-graphql}) + :get-vendor gq-vendors/get-graphql + :search-vendor gq-vendors/search}) gq-checks/attach gq-ledger/attach gq-reports/attach diff --git a/src/clj/auto_ap/graphql/vendors.clj b/src/clj/auto_ap/graphql/vendors.clj index c92fb166..037d3e09 100644 --- a/src/clj/auto_ap/graphql/vendors.clj +++ b/src/clj/auto_ap/graphql/vendors.clj @@ -117,3 +117,21 @@ (defn get-graphql [context args value] (->graphql (d-vendors/get-graphql (assoc args :id (:id context))))) + +(defn get-by-id [context args value] + (->graphql + (d-vendors/get-graphql-by-id (assoc args :id (:id context)) + (:id args)))) + +(defn search [context args value] + (->> (d/q '[:find ?n ?i ?s + :in $ ?q + :where [(fulltext $ :vendor/search-terms ?q) [[?i ?n _ ?s]]] + (not [?i :vendor/hidden true])] + (d/db conn) + (:query args)) + (sort-by last) + (map (fn [[n i]] + {:name n + :id i} + )))) diff --git a/src/cljs/auto_ap/forms.cljs b/src/cljs/auto_ap/forms.cljs index 86b58377..257cc6c1 100644 --- a/src/cljs/auto_ap/forms.cljs +++ b/src/cljs/auto_ap/forms.cljs @@ -10,6 +10,13 @@ (fn [db [_ x]] (get (-> db ::forms) x))) +(re-frame/reg-sub + ::field + (fn [db [_ x f]] + (-> (get (-> db ::forms) x) + :data + (get-in f)))) + (re-frame/reg-sub ::is-loading? diff --git a/src/cljs/auto_ap/views/components/expense_accounts_field.cljs b/src/cljs/auto_ap/views/components/expense_accounts_field.cljs index f02ecfd7..bbe45003 100644 --- a/src/cljs/auto_ap/views/components/expense_accounts_field.cljs +++ b/src/cljs/auto_ap/views/components/expense_accounts_field.cljs @@ -14,8 +14,7 @@ (not (get-in accounts [0 :account :id])))) (defn default-account [accounts default-account amount locations] - [{:id (get-in accounts [0 :id] - (str "new-" (random-uuid))) + [{:id (str "new-" (random-uuid)) :amount (Math/abs amount) :amount-percentage 100 :amount-mode "%" diff --git a/src/cljs/auto_ap/views/components/typeahead/vendor.cljs b/src/cljs/auto_ap/views/components/typeahead/vendor.cljs new file mode 100644 index 00000000..57b1ab37 --- /dev/null +++ b/src/cljs/auto_ap/views/components/typeahead/vendor.cljs @@ -0,0 +1,134 @@ +(ns auto-ap.views.components.typeahead.vendor + (:require + [downshift :as ds :refer [useCombobox]] + [re-frame.core :as re-frame] + [auto-ap.views.utils :refer [with-user]] + [react])) + +(set! *warn-on-infer* true) + +;; TODO: This avoids the use of inferred externs by using aget. You could just use the ^js tag though +(defn state-reducer [^js/FakeStateObject state ^js/FakeActionsAndChanges actions-and-changes] + (let [useCombobox ^js/Downshift useCombobox] + (cond + (= (.-type actions-and-changes) (.-InputChange (.-stateChangeTypes ^js/Downshift useCombobox))) + (set! (.-selectedItem (.-changes actions-and-changes)) nil) + + (and (= (.-type actions-and-changes) (.-InputBlur (.-stateChangeTypes ^js/Downshift useCombobox))) + (not (.-selectedItem state))) + (set! (.-inputValue (.-changes actions-and-changes )) + nil) + + :else + nil)) + (.-changes actions-and-changes)) + +(re-frame/reg-event-fx + ::search-completed + (fn [_ [_ set-items set-loading-status result]] + (set-loading-status nil) + (set-items (:search-results result)) + {})) +(re-frame/reg-event-fx + ::search-failed + (fn [_ _] + {})) + +(re-frame/reg-event-fx + ::input-value-settled + [with-user] + (fn [{:keys [user]} [_ input-value search-query set-items set-loading-status]] + (when (> (count input-value) 2) + (set-loading-status :loading) + + + {:graphql {:token user + :query-obj {:venia/queries [{:query/data (search-query input-value ) + :query/alias :search-results}]} + :on-success [::search-completed set-items set-loading-status] + :on-error [::search-failed set-loading-status]}}))) + +(re-frame/reg-event-fx + ::input-value-changed + (fn [_ [_ input-value search-query set-items set-loading-status]] + (set-items []) + (when (> (count input-value) 2) + (set-loading-status :loading) + {:dispatch-debounce {:event [::input-value-settled input-value search-query set-items set-loading-status] + :time 250 + :key ::input-value-settled}}))) + +(defn typeahead-v3-internal [{:keys [class style ^js entity->text on-change disabled value name search-query auto-focus] :or {disabled false} :as i}] + (let [[items set-items] (react/useState []) + [loading-status set-loading-status] (react/useState false) + [getLabelProps getMenuProps getComboboxProps getToggleButtonProps getInputProps getItemProps isOpen highlightedIndex selectItem selectedItem setInputValue] + (as-> (useCombobox (clj->js {:items items + :defaultHighlightedIndex 0 + :defaultSelectedItem value + :onInputValueChange (fn [input] + (re-frame/dispatch [::input-value-changed (aget input "inputValue") search-query set-items set-loading-status]) + true) + :stateReducer state-reducer + :onSelectedItemChange (fn [z] + (when on-change + (on-change (js->clj (aget z "selectedItem") :keywordize-keys true))))})) $ + (map #(aget $ %) ["getLabelProps" "getMenuProps" "getComboboxProps" "getToggleButtonProps" "getInputProps" "getItemProps" "isOpen" "highlightedIndex" "selectItem" "selectedItem" "setInputValue"]))] + #_(println (getInputProps)) + + [:<> + [:div.typeahead (assoc (js->clj (getComboboxProps)) + :style style) + (cond + selectedItem + ^{:key "typeahead"} + [:div.input (assoc (js->clj (getInputProps #js {:disabled (if disabled + "disabled" + "")})) + :on-key-up (fn [e] + (when (= 8 (aget e "keyCode" )) + (selectItem nil) + (setInputValue nil) + (when on-change + (on-change nil)))) + :class (if (= :loading loading-status) + "is-loading" + class) + :tab-index "0") + [:div.control + [:div.tags.has-addons + [:span.tag (entity->text (js->clj selectedItem :keywordize-keys true))] + (when name + [:input {:type "hidden" :name name :value (:id (js->clj selectedItem :keywordize-keys true))}]) + (when-not disabled + [:a.tag.is-delete {:on-click (fn [] + (setInputValue nil) + (selectItem nil) + (when on-change + (on-change nil)))}])]]] + + :else + ^{:key "typeahead"} [:div.control {:class (when (= :loading loading-status) + "is-loading")} + [:input.input (js->clj + (getInputProps #js {:disabled (if disabled + "disabled" + "") + :autoFocus (if auto-focus + "autoFocus" + "")}))]]) + [:div {:class (when (and isOpen (seq items)) + "typeahead-menu")} + [:ul (js->clj (getMenuProps)) + (if (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 (if (= index highlightedIndex) + "typeahead-highlighted")) + (entity->text item)]))]]]])) + +(defn search-backed-typeahead [props] + [:div + [:f> typeahead-v3-internal (assoc props + :entity->text :name + )]]) diff --git a/src/cljs/auto_ap/views/pages/invoices/form.cljs b/src/cljs/auto_ap/views/pages/invoices/form.cljs index 5fef263d..dc5e7441 100644 --- a/src/cljs/auto_ap/views/pages/invoices/form.cljs +++ b/src/cljs/auto_ap/views/pages/invoices/form.cljs @@ -17,6 +17,7 @@ [auto-ap.views.components.money-field :refer [money-field]] [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 :refer @@ -26,13 +27,14 @@ [clojure.string :as str] [re-frame.core :as re-frame] [reagent.core :as r] - [vimsical.re-frame.fx.track :as track])) + [vimsical.re-frame.fx.track :as track] + [vimsical.re-frame.cofx.inject :as inject])) ;; SUBS (re-frame/reg-sub ::can-submit :<- [::forms/form ::form] - (fn [{:keys [data status]} _] + (fn [{:keys [data]} _] (let [min-total (if (= (:total (:original data)) (:outstanding-balance (:original data))) nil (- (:total (:original data)) (:outstanding-balance (:original data)))) @@ -45,7 +47,7 @@ (re-frame/reg-sub ::create-query :<- [::forms/form ::form] - (fn [{:keys [data] {:keys [id invoice-number date due scheduled-payment location total expense-accounts vendor client]} :data}] + (fn [{{:keys [invoice-number date due scheduled-payment location total expense-accounts vendor client]} :data}] {:venia/operation {:operation/type :mutation :operation/name "AddInvoice"} :venia/queries [{:query/data [:add-invoice @@ -69,7 +71,7 @@ (re-frame/reg-sub ::edit-query :<- [::forms/form ::form] - (fn [{:keys [data] {:keys [id invoice-number date due scheduled-payment location total expense-accounts vendor client]} :data}] + (fn [{{:keys [id invoice-number date due scheduled-payment total expense-accounts]} :data}] {:venia/operation {:operation/type :mutation :operation/name "EditInvoice"} :venia/queries [{:query/data [:edit-invoice @@ -90,8 +92,8 @@ (re-frame/reg-sub ::add-and-print-query - (fn [db [_ bank-account-id type]] - (let [{:keys [data] {:keys [id invoice-number date location total expense-accounts scheduled-payment vendor client]} :data} @(re-frame/subscribe [::forms/form ::form])] + (fn [_ [_ bank-account-id type]] + (let [{{:keys [invoice-number date location total expense-accounts scheduled-payment vendor client]} :data} @(re-frame/subscribe [::forms/form ::form])] {:venia/operation {:operation/type :mutation :operation/name "AddAndPrintInvoice"} :venia/queries [{:query/data [:add-and-print-invoice @@ -119,7 +121,7 @@ (re-frame/reg-event-db ::updated - (fn [db [_ invoice command]] + (fn [db [_ _ command]] (if (= :create command) (-> db (forms/stop-form ::form ) @@ -178,34 +180,7 @@ (forms/change-handler ::form (fn [data field value] (let [locations @(re-frame/subscribe [::subs/locations-for-client (:id (:client data))])] - - (cond (and (= [:vendor] field) - value) - (let [schedule-payment-dom (get (by (comp :id :client ) :dom (:schedule-payment-dom value)) - (:id (:client data)))] - (cond-> [] - - (expense-accounts-field/can-replace-with-default? (:expense-accounts data)) - (into [[:expense-accounts] (expense-accounts-field/default-account (:expense-accounts data) - @(re-frame/subscribe [::subs/vendor-default-account (:id value) (:client data)]) - (:total data) - locations)]) - - - (boolean ((set (map :id (:automatically-paid-when-due value))) (:id (:client data)))) - (into [[:scheduled-payment] (:due data) - [:schedule-when-due] true - [:vendor-autopay? ] true]) - - - schedule-payment-dom - (into [[:scheduled-payment] (date->str (next-dom (str->date (:date data) standard) schedule-payment-dom) standard) - [:vendor-autopay?] true]) - - true - (into [[:schedule-payment-dom] schedule-payment-dom]))) - - (= [:total] field) + (cond (= [:total] field) [[:expense-accounts] (recalculate-amounts (:expense-accounts data) value)] (and (= [:date] field) @@ -231,7 +206,7 @@ (re-frame/reg-event-fx ::add-and-print [with-user (forms/in-form ::form)] - (fn [{:keys [user] {:keys [data]} :db} [_ bank-account-id type]] + (fn [{:keys [user]} [_ bank-account-id type]] {:graphql {:token user :owns-state {:single ::form} @@ -263,7 +238,7 @@ (re-frame/reg-event-fx ::save-requested [with-user (forms/in-form ::form)] - (fn [{:keys [user db]} [_ fwd-event]] + (fn [{:keys [db]} [_ fwd-event]] (if (and (:scheduled-payment (:data db)) (not (:vendor-autopay? (:data db)))) {:dispatch @@ -282,16 +257,80 @@ (re-frame/reg-event-fx ::added-and-printed - (fn [{:keys [db]} [_ result]] + (fn [_ [_ result]] (let [invoice (first (:invoices (:add-and-print-invoice result)))] {:dispatch-n [[::updated (assoc invoice :class "live-added") :create] [::checks-printed [invoice] (:pdf-url (:add-and-print-invoice result))]]}))) (re-frame/reg-event-db ::checks-printed - (fn [db [_ invoices pdf-url]] + (fn [db _] db)) +(re-frame/reg-sub + ::client-accounts + :<- [::forms/field ::form [:client]] + :<- [::subs/all-accounts] + (fn [[client all-accounts]] + (subs/accounts-by-id all-accounts client))) + +(re-frame/reg-event-fx + ::changed-vendor + [with-user (forms/in-form ::form) (re-frame/inject-cofx ::inject/sub [::client-accounts])] + (fn [{::keys [client-accounts] :keys [user] {{:keys [client date due expense-accounts total]} :data} :db} [_ vendor]] + (when (:id vendor) + {:graphql {:token user + :query-obj {:venia/queries [[:vendor-by-id + {:id (:id vendor)} + [[:automatically-paid-when-due [:id]] + [:schedule-payment-dom [[:client [:id]] :dom]] + [:default-account [:id]]]]]} + :on-success (fn [r] + (let [schedule-payment-dom (->> r + :vendor-by-id + :schedule-payment-dom + (filter (fn [spd] + (= (-> spd :client :id) + (:id client)))) + first + :dom) + + default-account (let [client-override (->> r + :vendor-by-id + :account-overrides + (filter #(= (:id (:client %)) (:id client))) + first + :account + :id) + default-id (->> r :vendor-by-id :default-account :id) + i (or client-override default-id)] + (client-accounts i)) + changes (cond-> [] + (expense-accounts-field/can-replace-with-default? expense-accounts) + (into [[:expense-accounts] (expense-accounts-field/default-account expense-accounts + default-account + total + (:locations client) + )]) + + (boolean ((->> r :vendor-by-id :automatically-paid-when-due (map :id) set) (:id client))) + (into [[:scheduled-payment] due + [:schedule-when-due] true + [:vendor-autopay? ] true]) + + schedule-payment-dom + (into [[:scheduled-payment] (date->str (next-dom (str->date date standard) schedule-payment-dom) standard) + [:vendor-autopay?] true]) + + true + (into [[:schedule-payment-dom] schedule-payment-dom]))] + (if (seq changes) + (into [::changed ] changes) + [:ignore])) + ) + :on-failure [:bad] + }}))) + ;; VIEWS @@ -308,16 +347,21 @@ :subscription [::subs/client] :event-fn (fn [c] - [::maybe-change-client c])}]})) + [::maybe-change-client c])} + {:id ::vendor-change + :subscription [::forms/field ::form [:vendor]] + :event-fn (fn [v] + [::changed-vendor v])}]})) (re-frame/reg-event-fx ::unmounted (fn [] - {::track/dispose [{:id ::client}]})) + {::track/dispose [{:id ::client} + {:id ::vendor-change}]})) -(defn form-content [{:keys [can-change-amount?] :as params}] +(defn form-content [params] [layouts/side-bar {:on-close (dispatch-event [::forms/form-closing ::form ])} - (let [{:keys [data active? error id]} @(re-frame/subscribe [::forms/form ::form]) + (let [{:keys [data id]} @(re-frame/subscribe [::forms/form ::form]) {:keys [form-inline field raw-field error-notification submit-button ]} invoice-form can-submit? (boolean @(re-frame/subscribe [::can-submit])) status @(re-frame/subscribe [::status/single ::form]) @@ -339,7 +383,7 @@ (not (seq (:payments data)))) [:div.tag.is-info.is-light "Automatically paid"] - (and (#{:paid ":paid"} (:status data))) + (#{:paid ":paid"} (:status data)) (if-let [check-number (:check-number (:payment (first (:payments data))))] [:div.tag.is-info.is-light "Paid by check #" check-number ] [:div.tag.is-info.is-light "Paid"]) @@ -359,13 +403,14 @@ :spec ::invoice/client}])) (field [:span "Vendor" [:span.has-text-danger " *"]] - [typeahead-v3 {:entities-by-id @(re-frame/subscribe [::subs/vendors-by-id]) - :entity-index @(re-frame/subscribe [::subs/searchable-vendors-index]) - :entity->text :name - :type "typeahead-v3" - :disabled exists? - :auto-focus (if @(re-frame/subscribe [::subs/client]) true false) - :field [:vendor]}]) + [search-backed-typeahead {:disabled exists? + :search-query (fn [i] + [:search_vendor + {:query i} + [:name :id]]) + :type "typeahead-v3" + :auto-focus (if @(re-frame/subscribe [::subs/client]) true false) + :field [:vendor]}]) (field [:span "Date" [:span.has-text-danger " *"]] @@ -374,6 +419,7 @@ :format-week-number (fn [] "") :previous-month-button-label "" :placeholder "mm/dd/yyyy" + :disable-keyboard-navigation true :next-month-button-label "" :next-month-label "" :type "date" @@ -386,6 +432,7 @@ :format-week-number (fn [] "") :previous-month-button-label "" :placeholder "mm/dd/yyyy" + :disable-keyboard-navigation true :next-month-button-label "" :next-month-label "" :type "date" @@ -440,11 +487,7 @@ :max (:total data) :client (or (:client data) @(re-frame/subscribe [::subs/client])) :field [:expense-accounts]}]) - {:key (str (:id (:vendor data)))}) - - - - + {:key (str (:id (:vendor data) "none") "-" (:id (:client data) "none") )}) (error-notification) @@ -464,7 +507,7 @@ :id ::add-and-print-invoice} [:div (list - (for [{:keys [id number name type]} (->> (:bank-accounts (:client data)) (filter :visible) (sort-by :sort-order))] + (for [{:keys [id name type]} (->> (:bank-accounts (:client data)) (filter :visible) (sort-by :sort-order))] (if (= :cash type) ^{:key id} [:a.dropdown-item {:on-click (dispatch-event [::save-requested [::add-and-print id :cash]])} "With cash"] (list @@ -476,7 +519,7 @@ {:key id}))]) -(defn form [p] +(defn form [_] (r/create-class {:display-name "invoice-form" :component-did-mount #(re-frame/dispatch [::mounted])