From 0830a26be972b74db9595b7dcd188cb15b517034 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 19 Jun 2024 17:11:28 +0200 Subject: [PATCH] Add error state for invalid name --- .../app/main/ui/workspace/tokens/common.cljs | 5 +- .../app/main/ui/workspace/tokens/common.scss | 4 + .../app/main/ui/workspace/tokens/modal.cljs | 177 +++++++++++------- .../app/main/ui/workspace/tokens/modal.scss | 6 + 4 files changed, 124 insertions(+), 68 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/tokens/common.cljs b/frontend/src/app/main/ui/workspace/tokens/common.cljs index 03b3d4239..6cb36ef18 100644 --- a/frontend/src/app/main/ui/workspace/tokens/common.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/common.cljs @@ -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]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/common.scss b/frontend/src/app/main/ui/workspace/tokens/common.scss index 30d611be7..9398a2bb2 100644 --- a/frontend/src/app/main/ui/workspace/tokens/common.scss +++ b/frontend/src/app/main/ui/workspace/tokens/common.scss @@ -18,6 +18,10 @@ } } +.labeled-input-error { + border: 1px solid var(--status-color-error-500) !important; +} + .button { @extend .button-primary; } diff --git a/frontend/src/app/main/ui/workspace/tokens/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/modal.cljs index d9738f713..9f1edc4a4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/modal.cljs @@ -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"]]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/modal.scss b/frontend/src/app/main/ui/workspace/tokens/modal.scss index c191f0bdf..403189783 100644 --- a/frontend/src/app/main/ui/workspace/tokens/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/modal.scss @@ -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;