0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-01-21 06:02:32 -05:00

Add error state for invalid name

This commit is contained in:
Florian Schroedl 2024-06-19 17:11:28 +02:00
parent 0c45d15fe7
commit 0830a26be9
4 changed files with 124 additions and 68 deletions

View file

@ -34,7 +34,7 @@
(mf/defc labeled-input
{::mf/wrap-props false}
[{:keys [label input-props auto-complete?]}]
[{:keys [label input-props auto-complete? error?]}]
(let [input-props (cond-> input-props
:always camel-keys
;; Disable auto-complete on form fields for proprietary password managers
@ -42,6 +42,7 @@
(not auto-complete?) (assoc "data-1p-ignore" true
"data-lpignore" true
:auto-complete "off"))]
[:label {:class (stl/css :labeled-input)}
[:label {:class (stl/css-case :labeled-input true
:error error?)}
[:span {:class (stl/css :label)} label]
[:& :input input-props]]))

View file

@ -18,6 +18,10 @@
}
}
.labeled-input-error {
border: 1px solid var(--status-color-error-500) !important;
}
.button {
@extend .button-primary;
}

View file

@ -16,6 +16,10 @@
[app.main.ui.workspace.tokens.common :as tokens.common]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.util.dom :as dom]
[clojure.set :as set]
[cuerdas.core :as str]
[malli.core :as m]
[malli.error :as me]
[okulary.core :as l]
[promesa.core :as p]
[rumext.v2 :as mf]))
@ -36,8 +40,11 @@
:else {:left (str (+ x 80) "px")
:top (str (- y 70 overflow-fix) "px")})))
(def viewport
(l/derived :vport refs/workspace-local))
(defn use-viewport-position-style [x y position]
(let [vport (-> (l/derived :vport refs/workspace-local)
(mf/deref))]
(-> (calculate-position vport position x y)
(clj->js))))
(defn fields->map [fields]
(->> (map (fn [{:keys [key] :as field}]
@ -76,81 +83,119 @@
debounced-fn (debounce callback-fn)]
debounced-fn))
(defn token-name-schema
"Generate a dynamic schema validation to check if a token name already exists.
`existing-token-names` should be a set of strings."
[existing-token-names]
(let [non-existing-token-schema
(m/-simple-schema
{:type :token/name-exists
:pred #(not (get existing-token-names %))
:type-properties {:error/fn #(str (:value %) " is an already existing token name")
:existing-token-names existing-token-names}})]
(m/schema
[:and
[:string {:min 1 :max 255}]
non-existing-token-schema])))
(comment
(-> (m/explain (token-name-schema #{"foo"}) nil)
(me/humanize))
nil)
(mf/defc tokens-properties-form
{::mf/wrap-props false}
[{:keys [token-type x y position fields token] :as args}]
(let [tokens (sd/use-resolved-workspace-tokens {:debug? true})
used-token-names (mf/use-memo
(mf/deps tokens)
(fn []
(-> (into #{} (map (fn [[_ {:keys [name]}]] name) tokens))
;; Allow setting token to already used name
(disj (:name token)))))
vport (mf/deref viewport)
style (calculate-position vport position x y)
[{:keys [x y position token] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position)
resolved-value (mf/use-state (get-in tokens [(:id token) :value]))
;; Tokens
tokens (sd/use-resolved-workspace-tokens)
existing-token-names (mf/use-memo
(mf/deps tokens)
(fn []
(-> (into #{} (map (fn [[_ {:keys [name]}]] name) tokens))
;; Allow setting token to already used name
(disj (:name token)))))
name (mf/use-var (or (:name token) ""))
on-update-name #(reset! name (dom/get-target-val %))
;; State
state* (mf/use-state (merge {:name ""
:value ""
:description ""}
token))
state @state*
token-value (mf/use-var (or (:value token) ""))
;; Name
finalize-name str/trim
name-schema (mf/use-memo
(mf/deps existing-token-names)
(fn []
(token-name-schema existing-token-names)))
on-update-name (fn [e]
(let [value (dom/get-target-val e)
errors (->> (finalize-name value)
(m/explain name-schema))]
(swap! state* merge {:name value
:errors/name errors})))
disabled? (or
(empty? (finalize-name (:name state)))
(:errors/name state))]
description (mf/use-var (or (:description token) ""))
on-update-description #(reset! description (dom/get-target-val %))
;; on-update-name (fn [e]
;; (let [{:keys [errors] :as state} (mf/deref state*)
;; value (-> (dom/get-target-val e)
;; (str/trim))]
;; (cond-> @state*
;; ;; Remove existing name errors
;; :always (update :errors set/difference #{:empty})
;; (str/empty?) (conj))
;; (swap! state* assoc :name (dom/get-target-val e))))
;; on-update-description #(swap! state* assoc :description (dom/get-target-val %))
;; on-update-field (fn [idx e]
;; (let [value (dom/get-target-val e)]
;; (swap! state* assoc-in [idx :value] value)))
initial-fields (mapv (fn [field]
(assoc field :value (or (:value token) "")))
fields)
state (mf/use-state initial-fields)
debounced-update (use-promise-debounce sd/resolve-tokens+
(fn [tokens]
(let [value (get-in tokens [(:id token) :value])]
(reset! resolved-value value)))
#(reset! resolved-value nil))
on-update-state-field (fn [idx e]
(let [value (dom/get-target-val e)]
(debounced-update)
(swap! state assoc-in [idx :value] value)))
on-submit (fn [e]
(dom/prevent-default e)
(let [token-value (-> (fields->map @state)
(first)
(val))
token (cond-> {:name @name
:type (or (:type token) token-type)
:value token-value}
@description (assoc :description @description)
(:id token) (assoc :id (:id token)))]
(st/emit! (dt/add-token token))
(modal/hide!)))]
;; on-submit (fn [e]
;; (dom/prevent-default e)
;; (let [token-value (-> (fields->map state)
;; (first)
;; (val))
;; token (cond-> {:name (:name state)
;; :type (or (:type token) token-type)
;; :value token-value
;; :description (:description state)}
;; (:id token) (assoc :id (:id token)))]
;; (st/emit! (dt/add-token token))
;; (modal/hide!)))]
[:form
{:class (stl/css :shadow)
:style (clj->js style)
:on-submit on-submit}
:style wrapper-style
#_#_:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:& tokens.common/labeled-input {:label "Name"
:input-props {:default-value @name
:auto-focus true
:on-change on-update-name}}]
(for [[idx {:keys [label type]}] (d/enumerate @state)]
[:* {:key (str "form-field-" idx)}
(case type
:box-shadow [:p "TODO BOX SHADOW"]
[:& tokens.common/labeled-input {:label "Value"
:input-props {:default-value @token-value
:on-change #(on-update-state-field idx %)}}])])
(when (and @resolved-value
(not= @resolved-value (:value (first @state))))
[:div {:class (stl/css :resolved-value)}
[:p @resolved-value]])
[:& tokens.common/labeled-input {:label "Description"
:input-props {:default-value @description
:on-change #(on-update-description %)}}]
[:div
[:& tokens.common/labeled-input {:label "Name"
:error? (:errors/name state)
:input-props {:default-value (:name state)
:auto-focus true
:on-change on-update-name}}]
(when-let [errors (:errors/name state)]
[:p {:class (stl/css :error)} (me/humanize errors)])]
#_(for [[idx {:keys [label type value]}] (d/enumerate (:fields state))]
[:* {:key (str "form-field-" idx)}
(case type
:box-shadow [:p "TODO BOX SHADOW"]
[:& tokens.common/labeled-input {:label "Value"
:input-props {:default-value value
:on-change #(on-update-field idx %)}}])])
;; (when (and @resolved-value
;; (not= @resolved-value (:value (first @state*))))
;; [:div {:class (stl/css :resolved-value)}
;; [:p @resolved-value]])
#_[:& tokens.common/labeled-input {:label "Description"
:input-props {:default-value (:description state)
:on-change #(on-update-description %)}}]
[:div {:class (stl/css :button-row)}
[:button {:class (stl/css :button)
:type "submit"}
:type "submit"
:disabled disabled?}
"Save"]]]]))

View file

@ -19,6 +19,12 @@
gap: $s-8;
}
.error {
@include bodySmallTypography;
margin-top: $s-6;
color: var(--status-color-error-500);
}
.resolved-value {
@include bodySmallTypography;
padding: $s-4 $s-6;