From 7fe965a870cd59b6be9af7f3ad658fbc1718495d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 17 Nov 2025 13:44:56 +0100 Subject: [PATCH] :tada: Add new form system on workspace (#7738) * :tada: Add new form system on border-radius token modals * :recycle: Create new namespace and separate components * :recycle: Refactor submit button --------- Co-authored-by: Andrey Antukh --- .../src/app/main/ui/components/forms.cljs | 2 +- frontend/src/app/main/ui/forms.cljs | 82 +++++++ .../management/create/border_radius.cljs | 220 ++++++++++++++++++ .../management/create/border_radius.scss | 58 +++++ .../tokens/management/create/form.cljs | 31 ++- .../management/create/form_input_token.cljs | 117 ++++++++++ frontend/src/app/util/forms.cljs | 1 + 7 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/main/ui/forms.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/form_input_token.cljs diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 0c7420255f..45419d56ca 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -426,7 +426,7 @@ (dom/prevent-default event) (when (fn? on-submit) (on-submit form event))))] - [:& (mf/provider form-ctx) {:value form} + [:> (mf/provider form-ctx) {:value form} [:form {:class class :on-submit on-submit'} children]])) (defn- conj-dedup diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs new file mode 100644 index 0000000000..e95128580c --- /dev/null +++ b/frontend/src/app/main/ui/forms.cljs @@ -0,0 +1,82 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.forms + (:require + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.keyboard :as k] + [rumext.v2 :as mf])) + +(def context (mf/create-context nil)) + +(mf/defc form-input* + [{:keys [name] :rest props}] + + (let [form (mf/use-ctx context) + input-name name + + touched? (and (contains? (:data @form) input-name) + (get-in @form [:touched input-name])) + error (get-in @form [:errors input-name]) + + value (get-in @form [:data input-name] "") + + on-change + (mf/use-fn + (mf/deps input-name) + (fn [event] + (let [value (-> event dom/get-target dom/get-input-value)] + (fm/on-input-change form input-name value true)))) + + + props + (mf/spread-props props {:on-change on-change + :default-value value}) + + props + (if (and error touched?) + (mf/spread-props props {:hint-type "error" + :hint-message (:message error)}) + props)] + + [:> input* props])) + +(mf/defc form-submit* + [{:keys [disabled on-submit] :rest props}] + + (let [form (mf/use-ctx context) + disabled? (or (and (some? form) + (or (not (:valid @form)) + (seq (:external-errors @form)))) + (true? disabled)) + handle-key-down-save + (mf/use-fn + (mf/deps on-submit form) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (dom/prevent-default e) + (on-submit form e)))) + + props + (mf/spread-props props {:disabled disabled? + :on-key-down handle-key-down-save + :type "submit"})] + + [:> button* props])) + +(mf/defc form* + [{:keys [on-submit form children class]}] + (let [on-submit' (mf/use-fn + (mf/deps on-submit) + (fn [event] + (dom/prevent-default event) + (when (fn? on-submit) + (on-submit form event))))] + [:> (mf/provider context) {:value form} + [:form {:class class :on-submit on-submit'} children]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.cljs new file mode 100644 index 0000000000..8c4b501f40 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.cljs @@ -0,0 +1,220 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.tokens.management.create.border-radius + (:require-macros [app.main.style :as stl]) + (:require + [app.common.files.tokens :as cft] + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.forms :as fc] + [app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- make-schema + [tokens-tree] + (sm/schema + [:and + [:map + [:name + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (cft/token-name-path-exists? % tokens-tree))]]] + + [:value ::sm/text] + + [:resolved-value ::sm/any] + + [:description {:optional true} + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] + + [:fn {:error/field :value + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (when (and name value) + (nil? (cto/token-value-self-reference? name value))))]])) + +(mf/defc form* + [{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}] + + (let [token + (mf/with-memo [token] + (or token {:type :border-radius})) + + token-type + (get token :type) + + token-properties + (dwta/get-token-properties token) + + token-title (str/lower (:title token-properties)) + + tokens + (mf/deref refs/workspace-active-theme-sets-tokens) + + tokens + (mf/with-memo [tokens] + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (cond-> tokens + (and (:name token) (:value token)) + (assoc (:name token) token))) + + schema + (mf/with-memo [tokens-tree-in-selected-set] + (make-schema tokens-tree-in-selected-set)) + + initial + (mf/with-memo [token] + {:name (:name token "") + :value (:value token "") + :description (:description token "")}) + + form + (fm/use-form :schema schema + :initial initial) + + warning-name-change? + (not= (get-in @form [:data :name]) + (:name initial)) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id token) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-cancel e)))) + + on-submit + (mf/use-fn + (mf/deps validate-token token tokens token-type) + (fn [form _event] + (let [name (get-in @form [:clean-data :name]) + description (get-in @form [:clean-data :description]) + value (get-in @form [:clean-data :value])] + (->> (validate-token {:token-value value + :token-name name + :token-description description + :prev-token token + :tokens tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))] + + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (tr "workspace.tokens.create-token" token-type)] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :input-row)} + [:> form-input-token* + {:placeholder (tr "workspace.tokens.token-value-enter") + :label (tr "workspace.tokens.token-value") + :name :value + :token token + :tokens tokens}]] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + + [:> fc/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.scss b/frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.scss new file mode 100644 index 0000000000..6593b3c30a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/border_radius.scss @@ -0,0 +1,58 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; + +.form-wrapper { + width: $sz-384; + position: relative; +} + +.token-rows { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.input-row { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.title-bar { + display: grid; + grid-template-columns: 1fr auto; +} + +.form-modal-title { + @include t.use-typography("headline-medium"); + color: var(--color-foreground-primary); + display: flex; + align-items: center; +} + +.button-row { + display: grid; + grid-template-columns: auto auto; + justify-content: end; + gap: var(--sp-m); + padding-block-start: var(--sp-s); +} + +.with-delete { + grid-template-columns: 1fr auto auto; +} + +.warning-name-change-notification-wrapper { + margin-block-start: var(--sp-l); +} + +.delete-btn { + justify-self: start; +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs index 3a0d9720bb..bb9bbc3f10 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs @@ -36,6 +36,7 @@ [app.main.ui.workspace.colorpicker :as colorpicker] [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [font-selector*]] + [app.main.ui.workspace.tokens.management.create.border-radius :as border-radius] [app.main.ui.workspace.tokens.management.create.input-token-color-bullet :refer [input-token-color-bullet*]] [app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-token* token-value-hint*]] [app.util.dom :as dom] @@ -1455,10 +1456,31 @@ (mf/defc form-wrapper* [{:keys [token token-type] :rest props}] - (let [token-type' (or (:type token) token-type) - props (mf/spread-props props {:token-type token-type' - :token token})] - (case token-type' + (let [token-type + (or (:type token) token-type) + ;; NOTE: All this references to tokens can be + ;; provided via context for + ;; avoid duplicate code among each form, this is because it is + ;; a common code and is probably will be needed on all forms + + tokens-in-selected-set + (mf/deref refs/workspace-all-tokens-in-selected-set) + + token-path + (mf/with-memo [token] + (cft/token-name->path (:name token))) + + tokens-tree-in-selected-set + (mf/with-memo [token-path tokens-in-selected-set] + (-> (ctob/tokens-tree tokens-in-selected-set) + (d/dissoc-in token-path))) + props + (mf/spread-props props {:token-type token-type + :validate-token default-validate-token + :tokens-tree-in-selected-set tokens-tree-in-selected-set + :token token})] + + (case token-type :color [:> color-form* props] :typography [:> typography-form* props] :shadow [:> shadow-form* props] @@ -1466,4 +1488,5 @@ :text-case [:> text-case-form* props] :text-decoration [:> text-decoration-form* props] :font-weight [:> font-weight-form* props] + :border-radius [:> border-radius/form* props] [:> form* props]))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form_input_token.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/form_input_token.cljs new file mode 100644 index 0000000000..64d0a32124 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form_input_token.cljs @@ -0,0 +1,117 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.tokens.management.create.form-input-token + (:require + [app.common.data :as d] + [app.common.types.tokens-lib :as ctob] + [app.main.data.style-dictionary :as sd] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.forms :as fc] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [beicon.v2.core :as rx] + [clojure.core :as c] + [rumext.v2 :as mf])) + +(defn- resolve-value + [tokens prev-token value] + (let [token + {:value value + :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"} + + tokens + (-> tokens + ;; Remove previous token when renaming a token + (dissoc (:name prev-token)) + (update (:name token) #(ctob/make-token (merge % prev-token token))))] + + (->> tokens + (sd/resolve-tokens-interactive) + (rx/mapcat + (fn [resolved-tokens] + (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))] + (if resolved-value + (rx/of {:value resolved-value}) + (rx/of {:error (first errors)})))))))) + +(mf/defc form-input-token* + [{:keys [name tokens token] :rest props}] + + (let [form (mf/use-ctx fc/context) + input-name name + + resolved-input-name + (mf/with-memo [input-name] + (keyword (str "resolved-" (c/name input-name)))) + + touched? + (and (contains? (:data @form) input-name) + (get-in @form [:touched input-name])) + + error + (get-in @form [:errors input-name]) + + value + (get-in @form [:data input-name] "") + + resolve-stream + (mf/with-memo [token] + (if-let [value (:value token)] + (rx/behavior-subject value) + (rx/subject))) + + hint* + (mf/use-state {}) + + hint + (deref hint*) + + on-change + (mf/use-fn + (mf/deps resolve-stream input-name) + (fn [event] + (let [value (-> event dom/get-target dom/get-input-value)] + (fm/on-input-change form input-name value true) + (rx/push! resolve-stream value)))) + + props + (mf/spread-props props {:on-change on-change + :default-value value + :hint-message (:message hint) + :hint-type (:type hint)}) + + props + (if (and error touched?) + (mf/spread-props props {:hint-type "error" + :hint-message (:message error)}) + props)] + + (mf/with-effect [resolve-stream tokens token input-name] + (let [subs (->> resolve-stream + (rx/debounce 300) + (rx/mapcat (partial resolve-value tokens token)) + (rx/map (fn [result] + (d/update-when result :error + (fn [error] + ((:error/fn error) (:error/value error)))))) + (rx/subs! (fn [{:keys [error value]}] + (if error + (do + (swap! form assoc-in [:errors input-name] {:message error}) + (swap! form assoc-in [:errors resolved-input-name] {:message error}) + (swap! form update :data dissoc resolved-input-name) + (reset! hint* {:message error :type "error"})) + (let [message (tr "workspace.tokens.resolved-value" value)] + (swap! form update :errors dissoc input-name resolved-input-name) + (swap! form update :data assoc resolved-input-name value) + (reset! hint* {:message message :type "hint"}))))))] + + (fn [] + (rx/dispose! subs)))) + + [:> input* props])) \ No newline at end of file diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 5e818a7a3c..968c319e7f 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -193,6 +193,7 @@ ([form field value trim?] (swap! form (fn [state] (-> state + (assoc-in [:touched field] true) (assoc-in [:data field] (if trim? (str/trim value) value)) (update :errors dissoc field))))))