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

♻️ Review create and edit modal

This commit is contained in:
Eva Marco 2024-10-21 17:14:17 +02:00
parent 31b5f5cefa
commit 03ea5414be
12 changed files with 310 additions and 147 deletions

View file

@ -279,7 +279,6 @@
ptk/WatchEvent
(watch [it state _]
(let [data (get state :workspace-data)
_ (prn "paso por aquí")
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/delete-token set-name token-name))]

View file

@ -130,3 +130,21 @@
box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgba(0, 0, 0, 0.2);
}
}
%base-button-action {
--button-bg-color: transparent;
--button-fg-color: var(--color-foreground-secondary);
--button-hover-bg-color: transparent;
--button-hover-fg-color: var(--color-accent-primary);
--button-active-bg-color: var(--color-background-quaternary);
--button-disabled-bg-color: transparent;
--button-disabled-fg-color: var(--color-accent-primary-muted);
--button-focus-bg-color: transparent;
--button-focus-fg-color: var(--color-accent-primary);
--button-focus-inner-ring-color: transparent;
--button-focus-outer-ring-color: var(--color-accent-primary);
}

View file

@ -12,9 +12,6 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
[rumext.v2 :as mf]))
(def button-variants (set '("primary" "secondary" "ghost" "destructive")))
(def ^:private schema:icon-button
[:map
[:class {:optional true} :string]
@ -22,7 +19,7 @@
[:and :string [:fn #(contains? icon-list %)]]]
[:aria-label :string]
[:variant {:optional true}
[:maybe [:enum "primary" "secondary" "ghost" "destructive"]]]])
[:maybe [:enum "primary" "secondary" "ghost" "destructive" "action"]]]])
(mf/defc icon-button*
{::mf/props :obj
@ -33,6 +30,7 @@
:icon-button-primary (= variant "primary")
:icon-button-secondary (= variant "secondary")
:icon-button-ghost (= variant "ghost")
:icon-button-action (= variant "action")
:icon-button-destructive (= variant "destructive")))
props (mf/spread-props props {:class class :title aria-label})]
[:> "button" props [:> icon* {:id icon :aria-label aria-label}]]))

View file

@ -31,3 +31,7 @@
.icon-button-destructive {
@extend %base-button-destructive;
}
.icon-button-action {
@extend %base-button-action;
}

View file

@ -26,7 +26,7 @@ export default {
},
disabled: { control: "boolean" },
variant: {
options: ["primary", "secondary", "ghost", "destructive"],
options: ["primary", "secondary", "ghost", "destructive", "action"],
control: { type: "select" },
},
},
@ -59,6 +59,12 @@ export const Ghost = {
},
};
export const Action = {
args: {
variant: "action",
},
};
export const Destructive = {
args: {
variant: "destructive",

View file

@ -10,12 +10,16 @@
["lodash.debounce" :as debounce]
[app.common.colors :as c]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.tokens-lib :as ctob]
[app.main.data.modal :as modal]
[app.main.data.tokens :as dt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.color-bullet :refer [color-bullet]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.workspace.colorpicker :as colorpicker]
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector]]
[app.main.ui.workspace.tokens.common :as tokens.common]
@ -25,6 +29,7 @@
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.update :as wtu]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[malli.core :as m]
[malli.error :as me]
@ -124,7 +129,7 @@ Token names should only contain letters and digits separated by . characters.")}
[name-ref token tokens callback & {:keys [timeout] :or {timeout 160}}]
(let [timeout-id-ref (mf/use-ref nil)
debounced-resolver-callback
(mf/use-callback
(mf/use-fn
(mf/deps token callback tokens)
(fn [value]
(let [timeout-id (js/Symbol)
@ -178,19 +183,22 @@ Token names should only contain letters and digits separated by . characters.")}
[{:keys [result-or-errors]}]
(let [{:keys [errors]} result-or-errors
empty-message? (or (nil? result-or-errors)
(wte/has-error-code? :error/empty-input errors))]
[:div {:class (stl/css-case :resolved-value true
:resolved-value-placeholder empty-message?
:resolved-value-error (seq errors))}
(cond
empty-message? "Enter token value"
errors (->> (wte/humanize-errors errors)
(str/join "\n"))
:else [:p result-or-errors])]))
(wte/has-error-code? :error/empty-input errors))
message (cond
empty-message? (dm/str (tr "workspace.token.resolved-value") "-")
errors (->> (wte/humanize-errors errors)
(str/join "\n"))
:else (dm/str (tr "workspace.token.resolved-value") result-or-errors))]
[:> text* {:as "p"
:typography "body-small"
:class (stl/css-case :resolved-value true
:resolved-value-placeholder empty-message?
:resolved-value-error (seq errors))}
message]))
(mf/defc form
{::mf/wrap-props false}
[{:keys [token token-type]}]
[{:keys [token token-type action selected-token-set-id]}]
(let [validate-name? (mf/use-state (not (:id token)))
token (or token {:type token-type})
color? (wtt/color-token? token)
@ -212,25 +220,31 @@ Token names should only contain letters and digits separated by . characters.")}
;; Name
name-ref (mf/use-var (:name token))
name-errors (mf/use-state nil)
validate-name (mf/use-callback
(mf/deps selected-set-tokens-tree)
(fn [value]
(let [schema (token-name-schema {:token token
:tokens-tree selected-set-tokens-tree})]
(m/explain schema (finalize-name value)))))
on-update-name-debounced (mf/use-callback
(debounce (fn [e]
(let [value (dom/get-target-val e)
errors (validate-name value)]
validate-name
(mf/use-fn
(mf/deps selected-set-tokens-tree)
(fn [value]
(let [schema (token-name-schema {:token token
:tokens-tree selected-set-tokens-tree})]
(m/explain schema (finalize-name value)))))
on-update-name-debounced
(mf/use-fn
(debounce (fn [e]
(let [value (dom/get-target-val e)
errors (validate-name value)]
;; Prevent showing error when just going to another field on a new token
(when-not (and validate-name? (str/empty? value))
(reset! validate-name? false)
(reset! name-errors errors))))))
on-update-name (mf/use-callback
(mf/deps on-update-name-debounced)
(fn [e]
(reset! name-ref (dom/get-target-val e))
(on-update-name-debounced e)))
(when-not (and validate-name? (str/empty? value))
(reset! validate-name? false)
(reset! name-errors errors))))))
on-update-name
(mf/use-fn
(mf/deps on-update-name-debounced)
(fn [e]
(reset! name-ref (dom/get-target-val e))
(on-update-name-debounced e)))
valid-name-field? (and
(not @name-errors)
(valid-name? @name-ref))
@ -241,27 +255,29 @@ Token names should only contain letters and digits separated by . characters.")}
value-input-ref (mf/use-ref nil)
value-ref (mf/use-var (:value token))
token-resolve-result (mf/use-state (get-in resolved-tokens [(wtt/token-identifier token) :resolved-value]))
set-resolve-value (mf/use-callback
(fn [token-or-err]
(let [error? (:errors token-or-err)
v (if error?
token-or-err
(:resolved-value token-or-err))]
(when color? (reset! color (if error? nil v)))
(reset! token-resolve-result v))))
set-resolve-value
(mf/use-fn
(fn [token-or-err]
(let [error? (:errors token-or-err)
v (if error?
token-or-err
(:resolved-value token-or-err))]
(when color? (reset! color (if error? nil v)))
(reset! token-resolve-result v))))
on-update-value-debounced (use-debonced-resolve-callback name-ref token active-theme-tokens set-resolve-value)
on-update-value (mf/use-callback
on-update-value (mf/use-fn
(mf/deps on-update-value-debounced)
(fn [e]
(let [value (dom/get-target-val e)]
(reset! value-ref value)
(on-update-value-debounced value))))
on-update-color (mf/use-callback
on-update-color (mf/use-fn
(mf/deps on-update-value-debounced)
(fn [hex-value]
(reset! value-ref hex-value)
(set! (.-value (mf/ref-val value-input-ref)) hex-value)
(on-update-value-debounced hex-value)))
value-error? (seq (:errors @token-resolve-result))
valid-value-field? (and
(not value-error?)
@ -270,17 +286,18 @@ Token names should only contain letters and digits separated by . characters.")}
;; Description
description-ref (mf/use-var (:description token))
description-errors (mf/use-state nil)
validate-descripion (mf/use-callback #(m/explain token-description-schema %))
on-update-description-debounced (mf/use-callback
validate-descripion (mf/use-fn #(m/explain token-description-schema %))
on-update-description-debounced (mf/use-fn
(debounce (fn [e]
(let [value (dom/get-target-val e)
errors (validate-descripion value)]
(reset! description-errors errors)))))
on-update-description (mf/use-callback
(mf/deps on-update-description-debounced)
(fn [e]
(reset! description-ref (dom/get-target-val e))
(on-update-description-debounced e)))
on-update-description
(mf/use-fn
(mf/deps on-update-description-debounced)
(fn [e]
(reset! description-ref (dom/get-target-val e))
(on-update-description-debounced e)))
valid-description-field? (not @description-errors)
;; Form
@ -288,41 +305,62 @@ Token names should only contain letters and digits separated by . characters.")}
(not valid-value-field?)
(not valid-description-field?))
on-submit (mf/use-callback
(mf/deps validate-name validate-descripion token resolved-tokens)
(fn [e]
(dom/prevent-default e)
on-submit
(mf/use-fn
(mf/deps validate-name validate-descripion token resolved-tokens)
(fn [e]
(dom/prevent-default e)
;; We have to re-validate the current form values before submitting
;; because the validation is asynchronous/debounced
;; and the user might have edited a valid form to make it invalid,
;; and press enter before the next validations could return.
(let [final-name (finalize-name @name-ref)
valid-name?+ (-> (validate-name final-name) schema-validation->promise)
final-value (finalize-value @value-ref)
final-description @description-ref
valid-description?+ (some-> final-description validate-descripion schema-validation->promise)]
(-> (p/all [valid-name?+
valid-description?+
(validate-token-value+ {:value final-value
:name-value final-name
:token token
:tokens resolved-tokens})])
(p/finally (fn [result err]
(let [final-name (finalize-name @name-ref)
valid-name?+ (-> (validate-name final-name) schema-validation->promise)
final-value (finalize-value @value-ref)
final-description @description-ref
valid-description?+ (some-> final-description validate-descripion schema-validation->promise)]
(-> (p/all [valid-name?+
valid-description?+
(validate-token-value+ {:value final-value
:name-value final-name
:token token
:tokens resolved-tokens})])
(p/finally (fn [result err]
;; The result should be a vector of all resolved validations
;; We do not handle the error case as it will be handled by the components validations
(when (and (seq result) (not err))
(st/emit! (dt/update-create-token {:token (ctob/make-token :name final-name
:type (or (:type token) token-type)
:value final-value
:description final-description)
:prev-token-name (:name token)}))
(st/emit! (wtu/update-workspace-tokens))
(modal/hide!))))))))]
[:form
{:class (stl/css :form-wrapper)
:on-submit on-submit}
(when (and (seq result) (not err))
(st/emit! (dt/update-create-token {:token (ctob/make-token :name final-name
:type (or (:type token) token-type)
:value final-value
:description final-description)
:prev-token-name (:name token)}))
(st/emit! (wtu/update-workspace-tokens))
(modal/hide!))))))))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dt/delete-token selected-token-set-id (:name token)))))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))]
[:form {:class (stl/css :form-wrapper)
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:div
[:> text* {:as "span" :typography "headline-medium"}
(if (= action "edit")
(tr "workspace.token.edit-token")
(tr "workspace.token.create-token" token-type))]
[:div {:class (stl/css :input-row)}
;; This should be remove when labeled-imput is modified
[:span "Name"]
[:& tokens.common/labeled-input {:label "Name"
:error? @name-errors
:input-props {:default-value @name-ref
@ -332,37 +370,59 @@ Token names should only contain letters and digits separated by . characters.")}
(for [error (->> (:errors @name-errors)
(map #(-> (assoc @name-errors :errors [%])
(me/humanize))))]
[:p {:key error
:class (stl/css :error)}
[:> text* {:as "p"
:key error
:typography "body-small"
:class (stl/css :error)}
error])]
[:& tokens.common/labeled-input {:label "Value"
:input-props {:default-value @value-ref
:on-blur on-update-value
:on-change on-update-value
:ref value-input-ref}
:render-right (when color?
(mf/fnc []
[:div {:class (stl/css :color-bullet)
:on-click #(swap! color-ramp-open? not)}
(if-let [hex (some-> @color tinycolor/valid-color tinycolor/->hex)]
[:& color-bullet {:color hex
:mini? true}]
[:div {:class (stl/css :color-bullet-placeholder)}])]))}]
(when @color-ramp-open?
[:& ramp {:color (some-> (or @token-resolve-result (:value token))
(tinycolor/valid-color))
:on-change on-update-color}])
[:& token-value-or-errors {:result-or-errors @token-resolve-result}]
[:div
[:div {:class (stl/css :input-row)}
;; This should be remove when labeled-imput is modified
[:span "value"]
[:& tokens.common/labeled-input {:label "Value"
:input-props {:default-value @value-ref
:on-blur on-update-value
:on-change on-update-value
:ref value-input-ref}
:render-right (when color?
(mf/fnc []
[:div {:class (stl/css :color-bullet)
:on-click #(swap! color-ramp-open? not)}
(if-let [hex (some-> @color tinycolor/valid-color tinycolor/->hex)]
[:& color-bullet {:color hex
:mini? true}]
[:div {:class (stl/css :color-bullet-placeholder)}])]))}]
(when @color-ramp-open?
[:& ramp {:color (some-> (or @token-resolve-result (:value token))
(tinycolor/valid-color))
:on-change on-update-color}])
[:& token-value-or-errors {:result-or-errors @token-resolve-result}]]
[:div {:class (stl/css :input-row)}
;; This should be remove when labeled-imput is modified
[:span "Description"]
[:& tokens.common/labeled-input {:label "Description"
:input-props {:default-value @description-ref
:on-change on-update-description}}]
(when @description-errors
[:p {:class (stl/css :error)}
[:> text* {:as "p"
:typography "body-small"
:class (stl/css :error)}
(me/humanize @description-errors)])]
[:div {:class (stl/css :button-row)}
[:button {:class (stl/css :button)
:type "submit"
:disabled disabled?}
"Save"]]]]))
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:class (stl/css :delete-btn)
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:variant "secondary"}
(tr "labels.cancel")]
[:> button* {:type "submit"
:variant "primary"
:disabled disabled?}
(tr "labels.save")]]]]))

View file

@ -8,49 +8,56 @@
@import "./common.scss";
.form-wrapper {
width: $s-260;
width: $s-384;
}
.button-row {
display: flex;
flex-direction: column;
margin-top: $s-16;
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: $s-12;
padding-block-start: $s-8;
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.delete-btn {
justify-self: start;
}
.token-rows {
display: flex;
flex-direction: column;
gap: $s-8;
gap: $s-16;
}
.input-row {
display: flex;
flex-direction: column;
gap: $s-4;
}
.error {
@include bodySmallTypography;
margin-top: $s-6;
padding: $s-4 $s-6;
margin-bottom: 0;
color: var(--status-color-error-500);
}
.resolved-value {
@include bodySmallTypography;
--input-hint-color: var(--color-foreground-primary);
margin-bottom: 0;
padding: $s-4 $s-6;
font-weight: medium;
min-height: 1lh;
color: var(--color-foreground-primary);
border: 1px solid color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent);
p {
font-size: $fs-12;
margin: 0;
}
color: var(--input-hint-color);
}
.resolved-value-placeholder {
color: var(--color-foreground-secondary);
--input-hint-color: var(--color-foreground-secondary);
}
.resolved-value-error {
color: var(--status-color-error-500);
--input-hint-color: var(--status-color-error-500);
}
.color-bullet {

View file

@ -8,9 +8,12 @@
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.modal :as modal]
[app.main.ui.workspace.tokens.modals.themes :as wtmt]
[app.main.refs :as refs]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.tokens.form :refer [form]]
[app.main.ui.workspace.tokens.modals.themes :as wtmt]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
@ -40,12 +43,21 @@
(mf/defc token-update-create-modal
{::mf/wrap-props false}
[{:keys [x y position token token-type] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position)]
[:div
{:class (stl/css :shadow)
:style wrapper-style}
[{:keys [x y position token token-type action selected-token-set-id] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position)
close-modal (mf/use-fn
(fn []
(modal/hide!)))]
[:div {:class (stl/css :token-modal-wrapper)
:style wrapper-style}
[:> icon-button* {:on-click close-modal
:class (stl/css :close-btn)
:icon i/close
:variant "action"
:aria-label (tr "labels.close")}]
[:& form {:token token
:action action
:selected-token-set-id selected-token-set-id
:token-type token-type}]]))
(mf/defc token-themes-modal

View file

@ -6,14 +6,19 @@
@import "refactor/common-refactor.scss";
.shadow {
.token-modal-wrapper {
@extend .modal-container-base;
width: auto;
max-width: auto;
min-width: auto;
@include menuShadow;
position: absolute;
width: auto;
min-width: auto;
z-index: 11;
overflow-y: auto;
overflow-x: hidden;
}
.close-btn {
position: absolute;
top: $s-6;
right: $s-6;
}

View file

@ -10,8 +10,8 @@
[app.common.data :as d]
[app.common.transit :as t]
[app.common.types.tokens-lib :as ctob]
[app.main.data.notifications :as ntf]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.tokens :as dt]
[app.main.refs :as refs]
[app.main.store :as st]
@ -33,6 +33,7 @@
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.token-types :as wtty]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@ -60,8 +61,8 @@
:title (cond
errors? (sd/humanize-errors token)
:else (->> [(str "Token: " name)
(str "Original value: " value)
(str "Resolved value: " resolved-value)]
(str (tr "workspace.token.original-value") value)
(str (tr "workspace.token.resolved-value") resolved-value)]
(str/join "\n")))
:on-click on-click
:on-context-menu on-context-menu
@ -111,6 +112,7 @@
#(st/emit! (dt/set-token-type-section-open type (not open?))))
on-popover-open-click (mf/use-fn
(fn [event]
(mf/deps type title)
(let [{:keys [key fields]} modal]
(dom/stop-propagation event)
(st/emit! (dt/set-token-type-section-open type true))
@ -118,6 +120,8 @@
:y (.-clientY ^js event)
:position :right
:fields fields
:title title
:action "create"
:token-type type}))))
on-token-pill-click (mf/use-fn
@ -175,16 +179,16 @@
:on-click (fn [e]
(dom/stop-propagation e)
(modal/show! :tokens/themes {}))}
(if create? "Create" "Edit")])
(if create? (tr "labels.create") (tr "labels.edit"))])
(mf/defc themes-header
[_props]
(let [ordered-themes (mf/deref refs/workspace-token-themes-no-hidden)]
[:div {:class (stl/css :themes-wrapper)}
[:span {:class (stl/css :themes-header)} "Themes"]
[:div {:class (stl/css :theme-select-wrapper)}
[:& theme-select]
[:& edit-button {:create? (empty? ordered-themes)}]]]))
[:div {:class (stl/css :themes-wrapper)}
[:span {:class (stl/css :themes-header)} (tr "labels.themes")]
[:div {:class (stl/css :theme-select-wrapper)}
[:& theme-select]
[:& edit-button {:create? (empty? ordered-themes)}]]]))
(mf/defc add-set-button
[{:keys [on-open]}]
@ -207,7 +211,7 @@
[:& title-bar {:collapsable true
:collapsed (not @open?)
:all-clickable true
:title "SETS"
:title (tr "labels.sets")
:on-collapsed #(swap! open? not)}
[:& add-set-button {:on-open on-open}]]]
(when @open?

View file

@ -1993,6 +1993,14 @@ msgstr "Team Leader"
msgid "labels.team-member"
msgstr "Team member"
#: src/app/main/ui/workspace/tokens/sidebar.cljs
msgid "labels.themes"
msgstr "Themes"
#: src/app/main/ui/workspace/tokens/sidebar.cljs
msgid "labels.sets"
msgstr "Sets"
#: src/app/main/ui/dashboard/sidebar.cljs:992, src/app/main/ui/workspace/main_menu.cljs:118
msgid "labels.tutorials"
msgstr "Tutorials"
@ -6139,3 +6147,20 @@ msgstr "Update"
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path"
#: src/app/main/ui/workspace/tokens/form.cljs
msgid "workspace.token.create-token"
msgstr "Create new %s token"
#: src/app/main/ui/workspace/tokens/form.cljs
msgid "workspace.token.edit-token"
msgstr "Edit token"
#: src/app/main/ui/workspace/tokens/form.cljs
msgid "workspace.token.resolved-value"
msgstr "Resolved value: "
#: src/app/main/ui/workspace/tokens/sidebar.cljs
msgid "workspace.token.original-value"
msgstr "Original value: "

View file

@ -1991,6 +1991,14 @@ msgstr "Líder de equipo"
msgid "labels.team-member"
msgstr "Miembro de equipo"
#: src/app/main/ui/workspace/tokens/sidebar.cljs
msgid "labels.themes"
msgstr "Temas"
#: src/app/main/ui/workspace/tokens/sidebar.cljs
msgid "labels.sets"
msgstr "Sets"
#: src/app/main/ui/dashboard/sidebar.cljs:992, src/app/main/ui/workspace/main_menu.cljs:118
msgid "labels.tutorials"
msgstr "Tutoriales"
@ -6126,3 +6134,20 @@ msgstr "Pulsar para cerrar la ruta"
msgid "errors.maximum-invitations-by-request-reached"
msgstr "Se ha alcanzado el número máximo (%s) de correos electrónicos que se pueden invitar en una sola solicitud"
#: src/app/main/ui/workspace/tokens/form.cljs
msgid "workspace.token.create-token"
msgstr "Crear un token de %s"
#: src/app/main/ui/workspace/tokens/form.cljs
msgid "workspace.token.edit-token"
msgstr "Editar token"
#: src/app/main/ui/workspace/tokens/form.cljs
msgid "workspace.token.resolved-value"
msgstr "Valor resuelto: "
#: src/app/main/ui/workspace/tokens/sidebar.cljs
msgid "workspace.token.original-value"
msgstr "Valor original: "