♻️ Refactor forms

Mainly replace spec with schema with better
and more reusable validations
This commit is contained in:
Andrey Antukh 2024-06-13 11:43:21 +02:00
parent f095e1b29f
commit 7be79c10fd
62 changed files with 786 additions and 1165 deletions

View file

@ -7,8 +7,8 @@
(ns app.main.ui.auth.login
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.logging :as log]
[app.common.schema :as sm]
[app.common.spec :as us]
[app.config :as cf]
[app.main.data.messages :as msg]
@ -72,20 +72,19 @@
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(defn handle-error-messages
[errors _data]
(d/update-when errors :email
(fn [{:keys [code] :as error}]
(cond-> error
(= code ::us/email)
(assoc :message (tr "errors.email-invalid"))))))
(def ^:private schema:login-form
[:map {:title "LoginForm"}
[:email [::sm/email {:error/code "errors.invalid-email"}]]
[:password [:string {:min 1}]]
[:invitation-token {:optional true}
[:string {:min 1}]]])
(mf/defc login-form
[{:keys [params on-success-callback origin] :as props}]
(let [initial (mf/use-memo (mf/deps params) (constantly params))
(let [initial (mf/with-memo [params] params)
error (mf/use-state false)
form (fm/use-form :spec ::login-form
:validators [handle-error-messages]
form (fm/use-form :schema schema:login-form
;; :validators [handle-error-messages]
:initial initial)
on-error

View file

@ -7,39 +7,29 @@
(ns app.main.ui.auth.recovery
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.messages :as msg]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::password-1 ::us/not-empty-string)
(s/def ::password-2 ::us/not-empty-string)
(s/def ::token ::us/not-empty-string)
(s/def ::recovery-form
(s/keys :req-un [::password-1
::password-2]))
(defn- password-equality
[errors data]
(let [password-1 (:password-1 data)
password-2 (:password-2 data)]
(cond-> errors
(and password-1 password-2
(not= password-1 password-2))
(assoc :password-2 {:message "errors.password-invalid-confirmation"})
(and password-1 (> 8 (count password-1)))
(assoc :password-1 {:message "errors.password-too-short"}))))
(def ^:private schema:recovery-form
[:and
[:map {:title "RecoveryForm"}
[:token ::sm/text]
[:password-1 ::sm/password]
[:password-2 ::sm/password]]
[:fn {:error/code "errors.password-invalid-confirmation"
:error/field :password-2}
(fn [{:keys [password-1 password-2]}]
(= password-1 password-2))]])
(defn- on-error
[_form _error]
(st/emit! (msg/error (tr "auth.notifications.invalid-token-error"))))
(st/emit! (msg/error (tr "errors.invalid-recovery-token"))))
(defn- on-success
[_]
@ -56,14 +46,13 @@
(mf/defc recovery-form
[{:keys [params] :as props}]
(let [form (fm/use-form :spec ::recovery-form
:validators [password-equality
(fm/validate-not-empty :password-1 (tr "auth.password-not-empty"))
(fm/validate-not-empty :password-2 (tr "auth.password-not-empty"))]
(let [form (fm/use-form :schema schema:recovery-form
:initial params)]
[:& fm/form {:on-submit on-submit
:class (stl/css :recovery-form)
:form form}
[:div {:class (stl/css :fields-row)}
[:& fm/input {:type "password"
:name :password-1

View file

@ -7,8 +7,7 @@
(ns app.main.ui.auth.recovery-request
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.messages :as msg]
[app.main.data.users :as du]
[app.main.store :as st]
@ -17,30 +16,24 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::email ::us/email)
(s/def ::recovery-request-form (s/keys :req-un [::email]))
(defn handle-error-messages
[errors _data]
(d/update-when errors :email
(fn [{:keys [code] :as error}]
(cond-> error
(= code :missing)
(assoc :message (tr "errors.email-invalid"))))))
(def ^:private schema:recovery-request-form
[:map {:title "RecoverRequestForm"}
[:email ::sm/email]])
(mf/defc recovery-form
[{:keys [on-success-callback] :as props}]
(let [form (fm/use-form :spec ::recovery-request-form
:validators [handle-error-messages]
(let [form (fm/use-form :schema schema:recovery-request-form
:initial {})
submitted (mf/use-state false)
default-success-finish #(st/emit! (msg/info (tr "auth.notifications.recovery-token-sent")))
default-success-finish
(mf/use-fn
#(st/emit! (msg/info (tr "auth.notifications.recovery-token-sent"))))
on-success
(mf/use-callback
(mf/use-fn
(fn [cdata _]
(reset! submitted false)
(if (nil? on-success-callback)
@ -48,7 +41,7 @@
(on-success-callback (:email cdata)))))
on-error
(mf/use-callback
(mf/use-fn
(fn [data cause]
(reset! submitted false)
(let [code (-> cause ex-data :code)]
@ -65,7 +58,7 @@
(rx/throw cause)))))
on-submit
(mf/use-callback
(mf/use-fn
(fn []
(reset! submitted true)
(let [cdata (:clean-data @form)

View file

@ -7,8 +7,7 @@
(ns app.main.ui.auth.register
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.common.schema :as sm]
[app.config :as cf]
[app.main.data.messages :as msg]
[app.main.data.users :as du]
@ -22,67 +21,42 @@
[app.util.router :as rt]
[app.util.storage :as sto]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
;; --- PAGE: Register
(defn- validate-password-length
[errors data]
(let [password (:password data)]
(cond-> errors
(> 8 (count password))
(assoc :password {:message "errors.password-too-short"}))))
(defn- validate-email
[errors _]
(d/update-when errors :email
(fn [{:keys [code] :as error}]
(cond-> error
(= code ::us/email)
(assoc :message (tr "errors.email-invalid"))))))
(s/def ::fullname ::us/not-empty-string)
(s/def ::password ::us/not-empty-string)
(s/def ::email ::us/email)
(s/def ::invitation-token ::us/not-empty-string)
(s/def ::terms-privacy ::us/boolean)
(s/def ::register-form
(s/keys :req-un [::password ::email]
:opt-un [::invitation-token]))
(defn- on-prepare-register-error
[form cause]
(let [{:keys [type code]} (ex-data cause)]
(condp = [type code]
[:restriction :registration-disabled]
(st/emit! (msg/error (tr "errors.registration-disabled")))
[:restriction :email-domain-is-not-allowed]
(st/emit! (msg/error (tr "errors.email-domain-not-allowed")))
[:validation :email-as-password]
(swap! form assoc-in [:errors :password]
{:message "errors.email-as-password"})
(st/emit! (msg/error (tr "errors.generic"))))))
(defn- on-prepare-register-success
[params]
(st/emit! (rt/nav :auth-register-validate {} params)))
(def ^:private schema:register-form
[:map {:title "RegisterForm"}
[:password ::sm/password]
[:email ::sm/email]
[:invitation-token {:optional true} ::sm/text]])
(mf/defc register-form
{::mf/props :obj}
[{:keys [params on-success-callback]}]
(let [initial (mf/use-memo (mf/deps params) (constantly params))
form (fm/use-form :spec ::register-form
:validators [validate-password-length
validate-email
(fm/validate-not-empty :password (tr "auth.password-not-empty"))]
form (fm/use-form :schema schema:register-form
:initial initial)
submitted? (mf/use-state false)
on-error
(mf/use-fn
(fn [form cause]
(let [{:keys [type code]} (ex-data cause)]
(condp = [type code]
[:restriction :registration-disabled]
(st/emit! (msg/error (tr "errors.registration-disabled")))
[:restriction :email-domain-is-not-allowed]
(st/emit! (msg/error (tr "errors.email-domain-not-allowed")))
[:validation :email-as-password]
(swap! form assoc-in [:errors :password]
{:code "errors.email-as-password"})
(st/emit! (msg/error (tr "errors.generic")))))))
on-submit
(mf/use-fn
(mf/deps on-success-callback)
@ -90,16 +64,14 @@
(reset! submitted? true)
(let [cdata (:clean-data @form)
on-success (fn [data]
(if (nil? on-success-callback)
(on-prepare-register-success data)
(on-success-callback data)))
on-error (fn [data]
(on-prepare-register-error form data))]
(if (fn? on-success-callback)
(on-success-callback data)
(st/emit! (rt/nav :auth-register-validate {} data))))]
(->> (rp/cmd! :prepare-register-profile cdata)
(rx/map #(merge % params))
(rx/finalize #(reset! submitted? false))
(rx/subs! on-success on-error)))))]
(rx/subs! on-success (partial on-error form))))))]
[:& fm/form {:on-submit on-submit :form form}
[:div {:class (stl/css :fields-row)}
@ -164,33 +136,6 @@
;; --- PAGE: register validation
(defn- on-register-success
[data]
(cond
(some? (:invitation-token data))
(let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token})))
(:is-active data)
(st/emit! (du/login-from-register))
:else
(do
(swap! sto/storage assoc ::email (:email data))
(st/emit! (rt/nav :auth-register-success)))))
(s/def ::accept-terms-and-privacy (s/and ::us/boolean true?))
(s/def ::accept-newsletter-subscription ::us/boolean)
(if (contains? cf/flags :terms-and-privacy-checkbox)
(s/def ::register-validate-form
(s/keys :req-un [::token ::fullname ::accept-terms-and-privacy]
:opt-un [::accept-newsletter-subscription]))
(s/def ::register-validate-form
(s/keys :req-un [::token ::fullname]
:opt-un [::accept-terms-and-privacy
::accept-newsletter-subscription])))
(mf/defc terms-and-privacy
{::mf/props :obj
::mf/private true}
@ -210,34 +155,48 @@
:default-checked false
:label terms-label}]]))
(def ^:private schema:register-validate-form
[:map {:title "RegisterValidateForm"}
[:token ::sm/text]
[:fullname [::sm/text {:max 250}]]
[:accept-terms-and-privacy {:optional (not (contains? cf/flags :terms-and-privacy-checkbox))}
[:and :boolean [:= true]]]])
(mf/defc register-validate-form
{::mf/props :obj}
{::mf/props :obj
::mf/private true}
[{:keys [params on-success-callback]}]
(let [validators (mf/with-memo []
[(fm/validate-not-empty :fullname (tr "auth.name.not-all-space"))
(fm/validate-length :fullname fm/max-length-allowed (tr "auth.name.too-long"))])
form (fm/use-form :spec ::register-validate-form
:validators validators
:initial params)
(let [form (fm/use-form :schema schema:register-validate-form :initial params)
submitted? (mf/use-state false)
on-success
(mf/use-fn
(mf/deps on-success-callback)
(fn [params]
(if (nil? on-success-callback)
(on-register-success params)
(on-success-callback (:email params)))))
(if (fn? on-success-callback)
(on-success-callback (:email params))
(cond
(some? (:invitation-token params))
(let [token (:invitation-token params)]
(st/emit! (rt/nav :auth-verify-token {} {:token token})))
(:is-active params)
(st/emit! (du/login-from-register))
:else
(do
(swap! sto/storage assoc ::email (:email params))
(st/emit! (rt/nav :auth-register-success)))))))
on-error
(mf/use-fn
(fn [_cause]
(fn [_]
(st/emit! (msg/error (tr "errors.generic")))))
on-submit
(mf/use-fn
(mf/deps on-success on-error)
(fn [form _]
(reset! submitted? true)
(let [params (:clean-data @form)]

View file

@ -18,7 +18,6 @@
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[cljs.core :as c]
[clojure.string]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@ -26,7 +25,9 @@
(def use-form fm/use-form)
(mf/defc input
[{:keys [label help-icon disabled form hint trim children data-testid on-change-value placeholder show-success?] :as props}]
[{:keys [label help-icon disabled form hint trim children data-testid on-change-value placeholder show-success? show-error]
:or {show-error true}
:as props}]
(let [input-type (get props :type "text")
input-name (get props :name)
more-classes (get props :class)
@ -152,11 +153,14 @@
children])
(cond
(and touched? (:message error))
[:div {:id (dm/str "error-" input-name)
:class (stl/css :error)
:data-testid (clojure.string/join [data-testid "-error"])}
(tr (:message error))]
(and touched? (:code error) show-error)
(let [code (:code error)]
[:div {:id (dm/str "error-" input-name)
:class (stl/css :error)
:data-testid (dm/str data-testid "-error")}
(if (vector? code)
(tr (nth code 0) (i18n/c (nth code 1)))
(tr code))])
(string? hint)
[:div {:class (stl/css :hint)} hint])]]))
@ -207,8 +211,8 @@
[:label {:class (stl/css :textarea-label)} label]
[:> :textarea props]
(cond
(and touched? (:message error))
[:span {:class (stl/css :error)} (tr (:message error))]
(and touched? (:code error))
[:span {:class (stl/css :error)} (tr (:code error))]
(string? hint)
[:span {:class (stl/css :hint)} hint])]))
@ -550,41 +554,3 @@
[:span {:class (stl/css :text)} (:text item)]
[:button {:class (stl/css :icon)
:on-click #(remove-item! item)} i/close]]])])]))
;; --- Validators
(defn all-spaces?
[value]
(let [trimmed (str/trim value)]
(str/empty? trimmed)))
(def max-length-allowed 250)
(def max-uri-length-allowed 2048)
(defn max-length?
[value length]
(> (count value) length))
(defn validate-length
[field length errors-msg]
(fn [errors data]
(cond-> errors
(max-length? (get data field) length)
(assoc field {:message errors-msg}))))
(defn validate-not-empty
[field error-msg]
(fn [errors data]
(cond-> errors
(all-spaces? (get data field))
(assoc field {:message error-msg}))))
(defn validate-not-all-spaces
[field error-msg]
(fn [errors data]
(let [value (get data field)]
(cond-> errors
(and
(all-spaces? value)
(> (count value) 0))
(assoc field {:message error-msg})))))

View file

@ -7,25 +7,24 @@
(ns app.main.ui.dashboard.change-owner
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::member-id ::us/uuid)
(s/def ::leave-modal-form
(s/keys :req-un [::member-id]))
(def ^:private schema:leave-modal-form
[:map {:title "LeaveModalForm"}
[:member-id ::sm/uuid]])
(mf/defc leave-and-reassign-modal
{::mf/register modal/components
::mf/register-as :leave-and-reassign}
[{:keys [profile team accept]}]
(let [form (fm/use-form :spec ::leave-modal-form :initial {})
(let [form (fm/use-form :schema schema:leave-modal-form :initial {})
members-map (mf/deref refs/dashboard-team-members)
members (vals members-map)

View file

@ -9,6 +9,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.spec :as us]
[app.config :as cfg]
[app.main.data.dashboard :as dd]
@ -33,7 +34,6 @@
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private arrow-icon
(i/icon-xref :arrow (stl/css :arrow-icon)))
@ -131,6 +131,12 @@
(s/def ::invite-member-form
(s/keys :req-un [::role ::emails ::team-id]))
(def ^:private schema:invite-member-form
[:map {:title "InviteMemberForm"}
[:role :keyword]
[:emails [::sm/set {:kind ::sm/email :min 1}]]
[:team-id ::sm/uuid]])
(mf/defc invite-members-modal
{::mf/register modal/components
::mf/register-as :invite-members
@ -139,9 +145,14 @@
(let [members-map (mf/deref refs/dashboard-team-members)
perms (:permissions team)
roles (mf/use-memo (mf/deps perms) #(get-available-roles perms))
initial (mf/use-memo (constantly {:role "editor" :team-id (:id team)}))
form (fm/use-form :spec ::invite-member-form
roles (mf/with-memo [perms]
(get-available-roles perms))
team-id (:id team)
initial (mf/with-memo [team-id]
{:role "editor" :team-id team-id})
form (fm/use-form :schema schema:invite-member-form
:initial initial)
error-text (mf/use-state "")
@ -746,10 +757,11 @@
;; WEBHOOKS SECTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::uri ::us/uri)
(s/def ::mtype ::us/not-empty-string)
(s/def ::webhook-form
(s/keys :req-un [::uri ::mtype]))
(def ^:private schema:webhook-form
[:map {:title "WebhookForm"}
[:uri [::sm/uri {:max 4069 :prefix #"^http[s]?://"
:error/code "errors.webhooks.invalid-uri"}]]
[:mtype ::sm/text]])
(def valid-webhook-mtypes
[{:label "application/json" :value "application/json"}
@ -763,12 +775,12 @@
{::mf/register modal/components
::mf/register-as :webhook}
[{:keys [webhook] :as props}]
;; FIXME: this is a workaround because input fields do not support rendering hooks
(let [initial (mf/use-memo (fn [] (or (some-> webhook (update :uri str))
{:is-active false :mtype "application/json"})))
form (fm/use-form :spec ::webhook-form
:initial initial
:validators [(fm/validate-length :uri fm/max-uri-length-allowed (tr "team.webhooks.max-length"))])
(let [initial (mf/with-memo []
(or (some-> webhook (update :uri str))
{:is-active false :mtype "application/json"}))
form (fm/use-form :schema schema:webhook-form
:initial initial)
on-success
(mf/use-fn
(fn [_]

View file

@ -7,7 +7,7 @@
(ns app.main.ui.dashboard.team-form
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
@ -19,12 +19,11 @@
[app.util.keyboard :as kbd]
[app.util.router :as rt]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(def ^:private schema:team-form
[:map {:title "TeamForm"}
[:name [::sm/text {:max 250}]]])
(defn- on-create-success
[_form response]
@ -68,24 +67,23 @@
(on-update-submit form)
(on-create-submit form))))
(mf/defc team-form-modal {::mf/register modal/components
::mf/register-as :team-form}
(mf/defc team-form-modal
{::mf/register modal/components
::mf/register-as :team-form}
[{:keys [team] :as props}]
(let [initial (mf/use-memo (fn [] (or team {})))
form (fm/use-form :spec ::team-form
:validators [(fm/validate-not-empty :name (tr "auth.name.not-all-space"))
(fm/validate-length :name fm/max-length-allowed (tr "auth.name.too-long"))]
form (fm/use-form :schema schema:team-form
:initial initial)
handle-keydown
(mf/use-callback
(mf/deps)
(mf/use-fn
(fn [e]
(when (kbd/enter? e)
(dom/prevent-default e)
(dom/stop-propagation e)
(on-submit form e))))
on-close #(st/emit! (modal/hide))]
on-close
(mf/use-fn #(st/emit! (modal/hide)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}

View file

@ -10,13 +10,13 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.main.data.events :as-alias ev]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@ -56,25 +56,20 @@
(tr "labels.start"))
:class (stl/css :next-button)}]]]))
(s/def ::questions-form-step-1
(s/keys :req-un [::planning
::expected-use]
:opt-un [::planning-other]))
(def ^:private schema:questions-form-1
[:and
(defn- step-1-form-validator
[errors data]
(let [planning (:planning data)
planning-other (:planning-other data)]
(cond-> errors
(and (= planning "other")
(str/blank? planning-other))
(assoc :planning-other {:code "missing"})
[:map {:title "QuestionsFormStep1"}
[:planning ::sm/text]
[:expected-use [:enum "work" "education" "personal"]]
[:planning-other {:optional true}
[::sm/text {:max 512}]]]
(not= planning "other")
(assoc :planning-other nil)
(str/blank? planning)
(assoc :planning {:code "missing"}))))
[:fn {:error/field :planning-other}
(fn [{:keys [planning planning-other]}]
(or (not= planning "other")
(and (= planning "other")
(not (str/blank? planning-other)))))]])
(mf/defc step-1
{::mf/props :obj}
@ -143,24 +138,24 @@
[:& fm/input {:name :planning-other
:class (stl/css :input-spacing)
:placeholder (tr "labels.other")
:show-error false
:label ""}])]]))
(s/def ::questions-form-step-2
(s/keys :req-un [::experience-design-tool]
:opt-un [::experience-design-tool-other]))
(def ^:private schema:questions-form-2
[:and
[:map {:title "QuestionsFormStep2"}
[:experience-design-tool
[:enum "figma" "sketch" "adobe-xd" "canva" "invision" "other"]]
[:experience-design-tool-other {:optional true}
[::sm/text {:max 512}]]]
(defn- step-2-form-validator
[errors data]
(let [experience (:experience-design-tool data)
experience-other (:experience-design-tool-other data)]
(cond-> errors
(and (= experience "other")
(str/blank? experience-other))
(assoc :experience-design-tool-other {:code "missing"})
(not= experience "other")
(assoc :experience-design-tool-other nil))))
[:fn {:error/field :experience-design-tool-other}
(fn [data]
(let [experience (:experience-design-tool data)
experience-other (:experience-design-tool-other data)]
(or (not= experience "other")
(and (= experience "other")
(not (str/blank? experience-other))))))]])
(mf/defc step-2
{::mf/props :obj}
@ -180,7 +175,7 @@
(conj {:label (tr "labels.other-short") :value "other" :icon i/curve})))
current-experience
(dm/get-in @form [:clean-data :experience-design-tool])
(dm/get-in @form [:data :experience-design-tool])
on-design-tool-change
(mf/use-fn
@ -212,33 +207,34 @@
[:& fm/input {:name :experience-design-tool-other
:class (stl/css :input-spacing)
:placeholder (tr "labels.other")
:show-error false
:label ""}])]]))
(s/def ::questions-form-step-3
(s/keys :req-un [::team-size ::role ::responsability]
:opt-un [::role-other ::responsability-other]))
(defn- step-3-form-validator
[errors data]
(let [role (:role data)
role-other (:role-other data)
responsability (:responsability data)
responsability-other (:responsability-other data)]
(def ^:private schema:questions-form-3
[:and
[:map {:title "QuestionsFormStep3"}
[:team-size
[:enum "more-than-50" "31-50" "11-30" "2-10" "freelancer" "personal-project"]]
[:role
[:enum "designer" "developer" "student-teacher" "graphic-design" "marketing" "manager" "other"]]
[:responsability
[:enum "team-leader" "team-member" "freelancer" "ceo-founder" "director" "student-teacher" "other"]]
(cond-> errors
(and (= role "other")
(str/blank? role-other))
(assoc :role-other {:code "missing"})
[:role-other {:optional true} [::sm/text {:max 512}]]
[:responsability-other {:optional true} [::sm/text {:max 512}]]]
(not= role "other")
(assoc :role-other nil)
[:fn {:error/field :role-other}
(fn [{:keys [role role-other]}]
(or (not= role "other")
(and (= role "other")
(not (str/blank? role-other)))))]
(and (= responsability "other")
(str/blank? responsability-other))
(assoc :responsability-other {:code "missing"})
(not= responsability "other")
(assoc :responsability-other nil))))
[:fn {:error/field :responsability-other}
(fn [{:keys [responsability responsability-other]}]
(or (not= responsability "other")
(and (= responsability "other")
(not (str/blank? responsability-other)))))]])
(mf/defc step-3
{::mf/props :obj}
@ -264,7 +260,6 @@
{:label (tr "labels.director") :value "director"}])
(conj {:label (tr "labels.other-short") :value "other"})))
team-size-options
(mf/with-memo []
[{:label (tr "labels.select-option") :value "" :key "team-size" :disabled true}
@ -301,6 +296,7 @@
[:& fm/input {:name :role-other
:class (stl/css :input-spacing)
:placeholder (tr "labels.other")
:show-error false
:label ""}])]
[:div {:class (stl/css :modal-question)}
@ -314,6 +310,7 @@
[:& fm/input {:name :responsability-other
:class (stl/css :input-spacing)
:placeholder (tr "labels.other")
:show-error false
:label ""}])]
[:div {:class (stl/css :modal-question)}
@ -323,21 +320,18 @@
:select-class (stl/css :select-class)
:name :team-size}]]]))
(s/def ::questions-form-step-4
(s/keys :req-un [::start-with]
:opt-un [::start-with-other]))
(def ^:private schema:questions-form-4
[:and
[:map {:title "QuestionsFormStep4"}
[:start-with
[:enum "ui" "wireframing" "prototyping" "ds" "code" "other"]]
[:start-with-other {:optional true} [::sm/text {:max 512}]]]
(defn- step-4-form-validator
[errors data]
(let [start (:start-with data)
start-other (:start-with-other data)]
(cond-> errors
(and (= start "other")
(str/blank? start-other))
(assoc :start-with-other {:code "missing"})
(not= start "other")
(assoc :start-with-other nil))))
[:fn {:error/field :start-with-other}
(fn [{:keys [start-with start-with-other]}]
(or (not= start-with "other")
(and (= start-with "other")
(not (str/blank? start-with-other)))))]])
(mf/defc step-4
{::mf/props :obj}
@ -386,23 +380,21 @@
[:& fm/input {:name :start-with-other
:class (stl/css :input-spacing)
:label ""
:show-error false
:placeholder (tr "labels.other")}])]]))
(s/def ::questions-form-step-5
(s/keys :req-un [::referer]
:opt-un [::referer-other]))
(def ^:private schema:questions-form-5
[:and
[:map {:title "QuestionsFormStep5"}
[:referer
[:enum "youtube" "event" "search" "social" "article" "other"]]
[:referer-other {:optional true} [::sm/text {:max 512}]]]
(defn- step-5-form-validator
[errors data]
(let [referer (:referer data)
referer-other (:referer-other data)]
(cond-> errors
(and (= referer "other")
(str/blank? referer-other))
(assoc :referer-other {:code "missing"})
(not= referer "other")
(assoc :referer-other nil))))
[:fn {:error/field :referer-other}
(fn [{:keys [referer referer-other]}]
(or (not= referer "other")
(and (= referer "other")
(not (str/blank? referer-other)))))]])
(mf/defc step-5
{::mf/props :obj}
@ -444,6 +436,7 @@
[:& fm/input {:name :referer-other
:class (stl/css :input-spacing)
:label ""
:show-error false
:placeholder (tr "labels.other")}])]]))
(mf/defc questions-modal
@ -456,28 +449,23 @@
;; and we want to keep the filled info
step-1-form (fm/use-form
:initial {}
:validators [step-1-form-validator]
:spec ::questions-form-step-1)
:schema schema:questions-form-1)
step-2-form (fm/use-form
:initial {}
:validators [step-2-form-validator]
:spec ::questions-form-step-2)
:schema schema:questions-form-2)
step-3-form (fm/use-form
:initial {}
:validators [step-3-form-validator]
:spec ::questions-form-step-3)
:schema schema:questions-form-3)
step-4-form (fm/use-form
:initial {}
:validators [step-4-form-validator]
:spec ::questions-form-step-4)
:schema schema:questions-form-4)
step-5-form (fm/use-form
:initial {}
:validators [step-5-form-validator]
:spec ::questions-form-step-5)
:schema schema:questions-form-5)
on-next
(mf/use-fn

View file

@ -8,7 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.dashboard :as dd]
[app.main.data.events :as ev]
[app.main.data.messages :as msg]
@ -18,7 +18,6 @@
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[cljs.spec.alpha :as s]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@ -55,11 +54,10 @@
[:p {:class (stl/css :modal-desc)}
(tr "onboarding.team-modal.create-team-feature-5")]]]])
(s/def ::emails (s/and ::us/set-of-valid-emails))
(s/def ::role ::us/keyword)
(s/def ::invite-form
(s/keys :req-un [::role ::emails]))
(def ^:private schema:invite-form
[:map {:title "InviteForm"}
[:role :keyword]
[:emails [::sm/set {:kind ::sm/email}]]])
(defn- get-available-roles
[]
@ -73,7 +71,7 @@
#(do {:role "editor"
:name name}))
form (fm/use-form :spec ::invite-form
form (fm/use-form :schema schema:invite-form
:initial initial)
params (:clean-data @form)
@ -151,7 +149,7 @@
:name :emails
:auto-focus? true
:trim true
:valid-item-fn us/parse-email
:valid-item-fn sm/parse-email
:caution-item-fn #{}
:label (tr "modals.invite-member.emails")
:on-submit on-submit}]]
@ -172,18 +170,16 @@
[:div {:class (stl/css :paginator)} "2/2"]]))
(def ^:private schema:team-form
[:map {:title "TeamForm"}
[:name [::sm/text {:max 250}]]])
(mf/defc team-form-step-1
{::mf/props :obj
::mf/private true}
[{:keys [on-submit]}]
(let [validators (mf/with-memo []
[(fm/validate-not-empty :name (tr "auth.name.not-all-space"))
(fm/validate-length :name fm/max-length-allowed (tr "auth.name.too-long"))])
form (fm/use-form
:spec ::team-form
:initial {}
:validators validators)
(let [form (fm/use-form :schema schema:team-form
:initial {})
on-submit*
(mf/use-fn
@ -240,10 +236,6 @@
[:div {:class (stl/css :paginator)} "1/2"]]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(mf/defc onboarding-team-modal
{::mf/props :obj}
[]

View file

@ -7,7 +7,7 @@
(ns app.main.ui.settings.access-tokens
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
[app.main.data.users :as du]
@ -20,8 +20,6 @@
[app.util.keyboard :as kbd]
[app.util.time :as dt]
[app.util.webapi :as wapi]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
@ -40,17 +38,10 @@
(def token-created-ref
(l/derived :access-token-created st/state))
(s/def ::name ::us/not-empty-string)
(s/def ::expiration-date ::us/not-empty-string)
(s/def ::access-token-form
(s/keys :req-un [::name ::expiration-date]))
(defn- name-validator
[errors data]
(let [name (:name data)]
(cond-> errors
(str/blank? name)
(assoc :name {:message (tr "dashboard.access-tokens.errors-required-name")}))))
(def ^:private schema:form
[:map {:title "AccessTokenForm"}
[:name [::sm/text {:max 250}]]
[:expiration-date [::sm/text {:max 250}]]])
(def initial-data
{:name "" :expiration-date "never"})
@ -61,10 +52,8 @@
[]
(let [form (fm/use-form
:initial initial-data
:spec ::access-token-form
:validators [name-validator
(fm/validate-not-empty :name (tr "auth.name.not-all-space"))
(fm/validate-length :name fm/max-length-allowed (tr "auth.name.too-long"))])
:schema schema:form)
created (mf/deref token-created-ref)
created? (mf/use-state false)
locale (mf/deref i18n/locale)

View file

@ -7,9 +7,7 @@
(ns app.main.ui.settings.change-email
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dma]
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
[app.main.data.users :as du]
@ -20,24 +18,8 @@
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::email-1 ::us/email)
(s/def ::email-2 ::us/email)
(defn- email-equality
[errors data]
(let [email-1 (:email-1 data)
email-2 (:email-2 data)]
(cond-> errors
(and email-1 email-2 (not= email-1 email-2))
(assoc :email-2 {:message (tr "errors.email-invalid-confirmation")
:code :different-emails}))))
(s/def ::email-change-form
(s/keys :req-un [::email-1 ::email-2]))
(defn- on-error
[form error]
(case (:code (ex-data error))
@ -71,30 +53,32 @@
:on-success (partial on-success profile)}]
(st/emit! (du/request-email-change (with-meta params mdata)))))
(def ^:private schema:email-change-form
[:and
[:map {:title "EmailChangeForm"}
[:email-1 ::sm/email]
[:email-2 ::sm/email]]
[:fn {:error/code "errors.invalid-email-confirmation"
:error/field :email-2}
(fn [data]
(let [email-1 (:email-1 data)
email-2 (:email-2 data)]
(= email-1 email-2)))]])
(mf/defc change-email-modal
{::mf/register modal/components
::mf/register-as :change-email}
[]
(let [profile (mf/deref refs/profile)
form (fm/use-form :spec ::email-change-form
:validators [email-equality]
form (fm/use-form :schema schema:email-change-form
:initial profile)
on-close
(mf/use-callback #(st/emit! (modal/hide)))
(mf/use-fn #(st/emit! (modal/hide)))
on-submit
(mf/use-callback
(mf/use-fn
(mf/deps profile)
(partial on-submit profile))
on-email-change
(mf/use-callback
(fn [_ _]
(let [different-emails-error? (= (dma/get-in @form [:errors :email-2 :code]) :different-emails)
email-1 (dma/get-in @form [:clean-data :email-1])
email-2 (dma/get-in @form [:clean-data :email-2])]
(when (and different-emails-error? (= email-1 email-2))
(swap! form d/dissoc-in [:errors :email-2])))))]
(partial on-submit profile))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
@ -118,16 +102,14 @@
:name :email-1
:label (tr "modals.change-email.new-email")
:trim true
:show-success? true
:on-change-value on-email-change}]]
:show-success? true}]]
[:div {:class (stl/css :fields-row)}
[:& fm/input {:type "email"
:name :email-2
:label (tr "modals.change-email.confirm-email")
:trim true
:show-success? true
:on-change-value on-email-change}]]]
:show-success? true}]]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)

View file

@ -8,7 +8,7 @@
"Feedback form."
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.messages :as msg]
[app.main.refs :as refs]
[app.main.repo :as rp]
@ -17,25 +17,22 @@
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::content ::us/not-empty-string)
(s/def ::subject ::us/not-empty-string)
(s/def ::feedback-form
(s/keys :req-un [::subject ::content]))
(def ^:private schema:feedback-form
[:map {:title "FeedbackForm"}
[:subject [::sm/text {:max 250}]]
[:content [::sm/text {:max 5000}]]])
(mf/defc feedback-form
{::mf/private true}
[]
(let [profile (mf/deref refs/profile)
form (fm/use-form :spec ::feedback-form
:validators [(fm/validate-length :subject fm/max-length-allowed (tr "auth.name.too-long"))
(fm/validate-not-empty :subject (tr "auth.name.not-all-space"))])
form (fm/use-form :schema schema:feedback-form)
loading (mf/use-state false)
on-succes
(mf/use-callback
(mf/use-fn
(mf/deps profile)
(fn [_]
(reset! loading false)
@ -43,7 +40,7 @@
(swap! form assoc :data {} :touched {} :errors {})))
on-error
(mf/use-callback
(mf/use-fn
(mf/deps profile)
(fn [{:keys [code] :as error}]
(reset! loading false)
@ -52,7 +49,7 @@
(st/emit! (msg/error (tr "errors.generic"))))))
on-submit
(mf/use-callback
(mf/use-fn
(mf/deps profile)
(fn [form _]
(reset! loading true)
@ -106,8 +103,8 @@
(mf/defc feedback-page
[]
(mf/use-effect
#(dom/set-html-title (tr "title.settings.feedback")))
(mf/with-effect []
(dom/set-html-title (tr "title.settings.feedback")))
[:div {:class (stl/css :dashboard-settings)}
[:div {:class (stl/css :form-container)}

View file

@ -7,7 +7,6 @@
(ns app.main.ui.settings.options
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.main.data.messages :as msg]
[app.main.data.users :as du]
[app.main.refs :as refs]
@ -15,14 +14,12 @@
[app.main.ui.components.forms :as fm]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::lang (s/nilable ::us/string))
(s/def ::theme (s/nilable ::us/not-empty-string))
(s/def ::options-form
(s/keys :opt-un [::lang ::theme]))
(def ^:private schema:options-form
[:map {:title "OptionsForm"}
[:lang {:optional true} [:string {:max 20}]]
[:theme {:optional true} [:string {:max 250}]]])
(defn- on-success
[profile]
@ -41,7 +38,7 @@
(let [profile (mf/deref refs/profile)
initial (mf/with-memo [profile]
(update profile :lang #(or % "")))
form (fm/use-form :spec ::options-form
form (fm/use-form :schema schema:options-form
:initial initial)]
[:& fm/form {:class (stl/css :options-form)

View file

@ -7,14 +7,13 @@
(ns app.main.ui.settings.password
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.messages :as msg]
[app.main.data.users :as udu]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(defn- on-error
@ -22,10 +21,10 @@
(case (:code (ex-data error))
:old-password-not-match
(swap! form assoc-in [:errors :password-old]
{:message (tr "errors.wrong-old-password")})
{:code "errors.wrong-old-password"})
:email-as-password
(swap! form assoc-in [:errors :password-1]
{:message (tr "errors.email-as-password")})
{:code "errors.email-as-password"})
(let [msg (tr "generic.error")]
(st/emit! (msg/error msg)))))
@ -47,40 +46,29 @@
:on-error (partial on-error form)})]
(st/emit! (udu/update-password params))))
(s/def ::password-1 ::us/not-empty-string)
(s/def ::password-2 ::us/not-empty-string)
(s/def ::password-old (s/nilable ::us/string))
(defn- password-equality
[errors data]
(let [password-1 (:password-1 data)
password-2 (:password-2 data)]
(cond-> errors
(and password-1 password-2 (not= password-1 password-2))
(assoc :password-2 {:message (tr "errors.password-invalid-confirmation")})
(and password-1 (> 8 (count password-1)))
(assoc :password-1 {:message (tr "errors.password-too-short")}))))
(s/def ::password-form
(s/keys :req-un [::password-1
::password-2
::password-old]))
(def ^:private schema:password-form
[:and
[:map {:title "PasswordForm"}
[:password-1 ::sm/password]
[:password-2 ::sm/password]
[:password-old ::sm/password]]
[:fn {:error/code "errors.password-invalid-confirmation"
:error/field :password-2}
(fn [{:keys [password-1 password-2]}]
(= password-1 password-2))]])
(mf/defc password-form
[]
(let [initial (mf/use-memo (constantly {:password-old nil}))
form (fm/use-form :spec ::password-form
:validators [(fm/validate-not-all-spaces :password-old (tr "auth.password-not-empty"))
(fm/validate-not-empty :password-1 (tr "auth.password-not-empty"))
(fm/validate-not-empty :password-2 (tr "auth.password-not-empty"))
password-equality]
:initial initial)]
(let [initial (mf/with-memo []
{:password-old ""
:password-1 ""
:password-2 ""})
form (fm/use-form :schema schema:password-form
:initial initial)]
[:& fm/form {:class (stl/css :password-form)
:on-submit on-submit
:form form}
[:div {:class (stl/css :fields-row)}
[:& fm/input
{:type "password"

View file

@ -7,7 +7,7 @@
(ns app.main.ui.settings.profile
(:require-macros [app.main.style :as stl])
(:require
[app.common.spec :as us]
[app.common.schema :as sm]
[app.config :as cf]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
@ -18,14 +18,12 @@
[app.main.ui.components.forms :as fm]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::fullname ::us/not-empty-string)
(s/def ::email ::us/email)
(s/def ::profile-form
(s/keys :req-un [::fullname ::email]))
(def ^:private schema:profile-form
[:map {:title "ProfileForm"}
[:fullname [::sm/text {:max 250}]]
[:email ::sm/email]])
(defn- on-submit
[form _event]
@ -37,19 +35,18 @@
;; --- Profile Form
(mf/defc profile-form
{::mf/private true}
[]
(let [profile (mf/deref refs/profile)
form (fm/use-form :spec ::profile-form
:initial profile
:validators [(fm/validate-length :fullname fm/max-length-allowed (tr "auth.name.too-long"))
(fm/validate-not-empty :fullname (tr "auth.name.not-all-space"))])
form (fm/use-form :schema schema:profile-form
:initial profile)
handle-show-change-email
(mf/use-callback
on-show-change-email
(mf/use-fn
#(modal/show! :change-email {}))
handle-show-delete-account
(mf/use-callback
on-show-delete-account
(mf/use-fn
#(modal/show! :delete-account {}))]
[:& fm/form {:on-submit on-submit
@ -62,7 +59,7 @@
:label (tr "dashboard.your-name")}]]
[:div {:class (stl/css :fields-row)
:on-click handle-show-change-email}
:on-click on-show-change-email}
[:& fm/input
{:type "email"
:name :email
@ -71,7 +68,7 @@
[:div {:class (stl/css :options)}
[:div.change-email
[:a {:on-click handle-show-change-email}
[:a {:on-click on-show-change-email}
(tr "dashboard.change-email")]]]]
[:> fm/submit-button*
@ -81,17 +78,25 @@
[:div {:class (stl/css :links)}
[:div {:class (stl/css :link-item)}
[:a {:on-click handle-show-delete-account
[:a {:on-click on-show-delete-account
:data-testid "remove-acount-btn"}
(tr "dashboard.remove-account")]]]]))
;; --- Profile Photo Form
(mf/defc profile-photo-form []
(let [file-input (mf/use-ref nil)
profile (mf/deref refs/profile)
photo (cf/resolve-profile-photo-url profile)
on-image-click #(dom/click (mf/ref-val file-input))
(mf/defc profile-photo-form
{::mf/private true}
[]
(let [input-ref (mf/use-ref nil)
profile (mf/deref refs/profile)
photo
(mf/with-memo [profile]
(cf/resolve-profile-photo-url profile))
on-image-click
(mf/use-fn
#(dom/click (mf/ref-val input-ref)))
on-file-selected
(fn [file]
@ -104,15 +109,17 @@
[:img {:src photo}]
[:& file-uploader {:accept "image/jpeg,image/png"
:multi false
:ref file-input
:ref input-ref
:on-selected on-file-selected
:data-testid "profile-image-input"}]]]))
;; --- Profile Page
(mf/defc profile-page []
(mf/defc profile-page
[]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.profile")))
[:div {:class (stl/css :dashboard-settings)}
[:div {:class (stl/css :form-container)}
[:h2 (tr "labels.profile")]

View file

@ -8,7 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.helpers :as cfh]
[app.common.spec :as us]
[app.common.schema :as sm]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.store :as st]
@ -18,7 +18,6 @@
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(mf/defc asset-group-title
@ -92,21 +91,18 @@
(compare key1 key2))))
assets)))
(s/def ::asset-name ::us/not-empty-string)
(s/def ::name-group-form
(s/keys :req-un [::asset-name]))
(def ^:private schema:group-form
[:map {:title "GroupForm"}
[:name [::sm/text {:max 250}]]])
(mf/defc name-group-dialog
{::mf/register modal/components
::mf/register-as :name-group-dialog}
[{:keys [path last-path accept] :as ctx
:or {path "" last-path ""}}]
(let [initial (mf/use-memo
(mf/deps last-path)
(constantly {:asset-name last-path}))
form (fm/use-form :spec ::name-group-form
:validators [(fm/validate-not-empty :asset-name (tr "auth.name.not-all-space"))
(fm/validate-length :asset-name fm/max-length-allowed (tr "auth.name.too-long"))]
(let [initial (mf/with-memo [last-path]
{:asset-name last-path})
form (fm/use-form :schema schema:group-form
:initial initial)
create? (empty? path)
@ -117,7 +113,7 @@
(mf/use-fn
(mf/deps form)
(fn [_]
(let [asset-name (get-in @form [:clean-data :asset-name])]
(let [asset-name (get-in @form [:clean-data :name])]
(if create?
(accept asset-name)
(accept path asset-name))
@ -135,7 +131,7 @@
[:div {:class (stl/css :modal-content)}
[:& fm/form {:form form :on-submit on-accept}
[:& fm/input {:name :asset-name
[:& fm/input {:name :name
:class (stl/css :input-wrapper)
:auto-focus? true
:label (tr "workspace.assets.group-name")

View file

@ -8,119 +8,146 @@
(:refer-clojure :exclude [uuid])
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.util.i18n :refer [tr]]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[malli.core :as m]
[rumext.v2 :as mf]))
;; --- Handlers Helpers
(defn- interpret-problem
[acc {:keys [path pred via] :as problem}]
(cond
(and (empty? path)
(list? pred)
(= (first (last pred)) 'cljs.core/contains?))
(let [field (last (last pred))
path (conj path field)
root (first via)]
(assoc-in acc path {:code :missing :type :builtin :root root :field field}))
(defn- interpret-schema-problem
[acc {:keys [schema in value] :as problem}]
(let [props (merge (m/type-properties schema)
(m/properties schema))
field (or (first in) (:error/field props))]
(and (seq path) (seq via))
(let [field (first path)
code (last via)
root (first via)]
(assoc-in acc path {:code code :type :builtin :root root :field field}))
(if (contains? acc field)
acc
(cond
(nil? value)
(assoc acc field {:code "errors.field-missing"})
:else acc))
(contains? props :error/code)
(assoc acc field {:code (:error/code props)})
(declare create-form-mutator)
(contains? props :error/message)
(assoc acc field {:code (:error/message props)})
(defn use-form
[& {:keys [initial] :as opts}]
(let [state (mf/useState 0)
render (aget state 1)
(contains? props :error/fn)
(let [v-fn (:error/fn props)
code (v-fn problem)]
(assoc acc field {:code code}))
get-state (mf/use-callback
(mf/deps initial)
(fn []
{:data (if (fn? initial) (initial) initial)
:errors {}
:touched {}}))
(contains? props :error/validators)
(let [validators (:error/validators props)
props (reduce #(%2 %1 value) props validators)]
(assoc acc field {:code (d/nilv (:error/code props) "errors.invalid-data")}))
state-ref (mf/use-ref (get-state))
form (mf/use-memo (mf/deps initial) #(create-form-mutator state-ref render get-state opts))]
:else
(assoc acc field {:code "errors.invalid-data"})))))
(mf/use-effect
(mf/deps initial)
(defn- use-rerender-fn
[]
(let [state (mf/useState 0)
render-fn (aget state 1)]
(mf/use-fn
(mf/deps render-fn)
(fn []
(if (fn? initial)
(swap! form update :data merge (initial))
(swap! form update :data merge initial))))
(render-fn inc)))))
form))
(defn- apply-validators
[validators state errors]
(reduce (fn [errors validator-fn]
(merge errors (validator-fn errors (:data state))))
errors
validators))
(defn- wrap-update-fn
[f {:keys [spec validators]}]
(defn- collect-schema-errors
[schema validators state]
(let [explain (sm/explain schema (:data state))
errors (->> (reduce interpret-schema-problem {} (:errors explain))
(apply-validators validators state))]
(-> (:errors state)
(merge errors)
(d/without-nils)
(not-empty))))
(defn- wrap-update-schema-fn
[f {:keys [schema validators]}]
(fn [& args]
(let [state (apply f args)
cleaned (s/conform spec (:data state))
problems (when (= ::s/invalid cleaned)
(::s/problems (s/explain-data spec (:data state))))
errors (reduce interpret-problem {} problems)
errors (reduce (fn [errors vf]
(merge errors (vf errors (:data state))))
errors
validators)
errors (merge (:errors state) errors)
errors (d/without-nils errors)]
(let [state (apply f args)
cleaned (sm/decode schema (:data state))
valid? (sm/validate schema cleaned)
errors (when-not valid?
(collect-schema-errors schema validators state))]
(assoc state
:errors errors
:clean-data (when (not= cleaned ::s/invalid) cleaned)
:valid (and (empty? errors)
(not= cleaned ::s/invalid))))))
:clean-data (when valid? cleaned)
:valid (and (not errors) valid?)))))
(defn- create-form-mutator
[state-ref render get-state opts]
[internal-state rerender-fn wrap-update-fn initial opts]
(reify
IDeref
(-deref [_]
(mf/ref-val state-ref))
(mf/ref-val internal-state))
IReset
(-reset! [_ new-value]
(if (nil? new-value)
(mf/set-ref-val! state-ref (get-state))
(mf/set-ref-val! state-ref new-value))
(render inc))
(mf/set-ref-val! internal-state (if (fn? initial) (initial) initial))
(mf/set-ref-val! internal-state new-value))
(rerender-fn))
ISwap
(-swap! [_ f]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (f (mf/ref-val state-ref)))
(render inc)))
(mf/set-ref-val! internal-state (f (mf/ref-val internal-state)))
(rerender-fn)))
(-swap! [_ f x]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (f (mf/ref-val state-ref) x))
(render inc)))
(mf/set-ref-val! internal-state (f (mf/ref-val internal-state) x))
(rerender-fn)))
(-swap! [_ f x y]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (f (mf/ref-val state-ref) x y))
(render inc)))
(mf/set-ref-val! internal-state (f (mf/ref-val internal-state) x y))
(rerender-fn)))
(-swap! [_ f x y more]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (apply f (mf/ref-val state-ref) x y more))
(render inc)))))
(mf/set-ref-val! internal-state (apply f (mf/ref-val internal-state) x y more))
(rerender-fn)))))
(defn use-form
[& {:keys [initial] :as opts}]
(let [rerender-fn (use-rerender-fn)
internal-state
(mf/use-ref nil)
form-mutator
(mf/with-memo [initial]
(create-form-mutator internal-state rerender-fn wrap-update-schema-fn initial opts))]
;; Initialize internal state once
(mf/with-effect []
(mf/set-ref-val! internal-state
{:data {}
:errors {}
:touched {}}))
(mf/with-effect [initial]
(if (fn? initial)
(swap! form-mutator update :data merge (initial))
(swap! form-mutator update :data merge initial)))
form-mutator))
(defn on-input-change
([form field value]
@ -150,8 +177,8 @@
(mf/defc field-error
[{:keys [form field type]
:as props}]
(let [{:keys [message] :as error} (get-in form [:errors field])
touched? (get-in form [:touched field])
(let [{:keys [message] :as error} (dm/get-in form [:errors field])
touched? (dm/get-in form [:touched field])
show? (and touched? error message
(cond
(nil? type) true
@ -164,12 +191,6 @@
(defn error-class
[form field]
(when (and (get-in form [:errors field])
(get-in form [:touched field]))
(when (and (dm/get-in form [:errors field])
(dm/get-in form [:touched field]))
"invalid"))
;; --- Form Specs and Conformers
(s/def ::email ::us/email)
(s/def ::not-empty-string ::us/not-empty-string)
(s/def ::color ::us/rgb-color-str)