Improve dashboard invitations modal (#10459)

This commit is contained in:
Luis de Dios 2026-07-03 09:28:52 +02:00 committed by GitHub
parent 841b47736b
commit 2c4a9e0f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 208 additions and 532 deletions

View File

@ -10,7 +10,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.ui.components.select :as cs]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.forms :as fm]
@ -450,189 +449,3 @@
(on-submit form event))))]
[:> (mf/provider form-ctx) {:value form}
[:form {:class class :on-submit on-submit'} children]]))
(defn- conj-dedup
"A helper that adds item into a vector and removes possible
duplicates. This is not very efficient implementation but is ok for
handling form input that will have a small number of items."
[coll item]
(into [] (distinct) (conj coll item)))
(mf/defc multi-input
[{:keys [form label class trim valid-item-fn caution-item-fn on-submit] :as props}]
(let [form (or form (mf/use-ctx form-ctx))
input-name (get props :name)
touched? (get-in @form [:touched input-name])
error (get-in @form [:errors input-name])
focus? (mf/use-state false)
auto-focus? (get props :auto-focus? false)
items (mf/use-state
(fn []
(let [initial (get-in @form [:data input-name])]
(if (or (vector? initial)
(set? initial))
(mapv (fn [val]
{:text val
:valid (valid-item-fn val)
:caution (caution-item-fn val)})
initial)
[]))))
value (mf/use-state "")
result (hooks/use-equal-memo @items)
empty? (and (str/empty? @value)
(zero? (count @items)))
klass (str (get props :class) " "
(stl/css-case
:focus @focus?
:valid (and touched? (not error))
:invalid (and touched? error)
:empty empty?
:custom-multi-input true))
in-klass (str class " "
(stl/css-case
:inside-input true
:no-padding (pos? (count @items))
:invalid (and (some? valid-item-fn)
touched?
(not (str/empty? @value))
(not (valid-item-fn @value)))))
on-focus
(mf/use-fn #(reset! focus? true))
on-change
(mf/use-fn
(fn [event]
(let [content (-> event dom/get-target dom/get-input-value)]
(reset! value content))))
update-form!
(mf/use-fn
(mf/deps form)
(fn [items]
(let [value (str/join " " (map :text items))]
(fm/update-input-value! form input-name value))))
on-key-down
(mf/use-fn
(mf/deps @value)
(fn [event]
(let [val (cond-> @value trim str/trim)]
(cond
(or (kbd/enter? event) (kbd/comma? event) (kbd/space? event))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
;; Once enter/comma is pressed we mark it as touched
(swap! form assoc-in [:touched input-name] true)
;; Empty values means "submit" the form (whent some items have been added
(when (and (kbd/enter? event) (str/empty? @value) (not-empty @items))
(when (fn? on-submit)
(on-submit form event)))
;; If we have a string in the input we add it only if valid
(when (and (valid-item-fn val) (not (str/empty? @value)))
(reset! value "")
;; Once added the form is back as "untouched"
(swap! form assoc-in [:touched input-name] false)
;; This split will allow users to copy comma/space separated values of emails
(doseq [val (str/split val #",|\s+")]
(swap! items conj-dedup {:text (str/trim val)
:valid (valid-item-fn val)
:caution (caution-item-fn val)}))))
(and (kbd/backspace? event) (str/empty? @value))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(swap! items (fn [items] (if (c/empty? items) items (pop items)))))))))
on-paste
(mf/use-fn
(fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched
(swap! form assoc-in [:touched input-name] true)
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched input-name] false))))))
on-blur
(mf/use-fn
(fn [_]
(reset! focus? false)
(when-not (get-in @form [:touched input-name])
(swap! form assoc-in [:touched input-name] true))))
remove-item!
(mf/use-fn
(fn [item]
(swap! items #(into [] (remove (fn [x] (= x item))) %))))
manage-key-down
(mf/use-fn
(fn [item event]
(when (kbd/enter? event)
(remove-item! item))))]
(mf/with-effect [result @value]
(let [val (cond-> @value trim str/trim)
values (conj-dedup result {:text val :valid (valid-item-fn val)})
values (filterv #(:valid %) values)]
(update-form! values)))
[:div {:class klass}
[:input {:id (name input-name)
:name (name input-name)
:class in-klass
:type "text"
:auto-focus auto-focus?
:on-focus on-focus
:on-blur on-blur
:on-key-down on-key-down
:on-paste on-paste
:value @value
:on-change on-change
:placeholder (when empty? label)}]
[:label {:for (name input-name)} label]
(when-let [items (seq @items)]
[:div {:class (stl/css :selected-items)}
(for [item items]
[:div {:class (stl/css :selected-item)
:key (:text item)
:tab-index "0"
:on-key-down (partial manage-key-down item)}
[:span {:class (stl/css-case :around true
:invalid (not (:valid item))
:caution (:caution item))}
[:span {:class (stl/css :text)} (:text item)]
[:button {:class (stl/css :icon)
:on-click #(remove-item! item)} deprecated-icon/close]]])])]))

View File

@ -326,112 +326,6 @@
}
}
// MULTI INPUT
.custom-multi-input {
display: flex;
flex-direction: column;
position: relative;
min-block-size: $sz-40;
max-block-size: px2rem(180);
inline-size: 100%;
overflow-y: hidden;
.inside-input {
@include deprecated.remove-input-style;
@include t.use-typography("body-small");
@include text-ellipsis;
inline-size: 100%;
max-inline-size: calc(100% - deprecated.$s-1);
min-block-size: $sz-32;
padding-block-start: 0;
block-size: $sz-32;
padding: var(--sp-s);
margin: 0;
border-radius: var(--sp-s);
color: var(--input-foreground-color-active);
background-color: var(--input-background-color);
&:focus {
outline: none;
border: $b-1 solid var(--input-border-color-focus);
}
&.invalid {
border: $b-1 solid var(--input-border-color-error);
&:hover,
&:focus {
border: $b-1 solid var(--input-border-color-error);
}
}
}
label {
display: none;
}
.selected-items {
display: flex;
flex-wrap: wrap;
gap: var(--sp-xs);
max-block-size: px2rem(136);
padding: var(--sp-xs) 0;
overflow-y: auto;
.selected-item {
.around {
display: flex;
align-items: center;
gap: var(--sp-xs);
block-size: $sz-24;
inline-size: fit-content;
padding-inline-start: px2rem(6);
border-radius: $br-6;
background-color: var(--pill-background-color);
border: $b-1 solid var(--pill-background-color);
box-sizing: border-box;
.text {
@include t.use-typography("body-small");
padding-inline-end: var(--sp-s);
color: var(--pill-foreground-color);
}
.icon {
display: flex;
justify-content: center;
align-items: center;
border: none;
background: none;
cursor: pointer;
block-size: $sz-32;
inline-size: $sz-24;
svg {
@extend %button-icon-small;
stroke: var(--icon-foreground);
}
}
&.invalid {
background-color: var(--status-widget-background-color-error);
.text {
color: var(--alert-text-foreground-color-error);
}
.icon svg {
stroke: var(--alert-text-foreground-color-error);
}
}
}
}
}
}
// RADIO BUTTONS
.custom-radio {
display: grid;

View File

@ -23,6 +23,7 @@
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
@ -88,11 +89,9 @@
[:div {:class (stl/css :info)}
[:span (tr "dasboard.team-hero.text")]
[:a {:on-click on-nav-members-click} (tr "dasboard.team-hero.management")]]
[:button
{:class (stl/css :btn-primary :invite)
:on-click on-invite}
[:> button* {:variant "primary"
:on-click on-invite}
(tr "onboarding.choice.team-up.invite-members")]]
[:button {:class (stl/css :close)
:on-click on-close'
:aria-label (tr "labels.close")}

View File

@ -244,11 +244,6 @@
stroke: var(--close-icon-foreground-color);
}
.invite {
block-size: $sz-32;
inline-size: px2rem(180);
}
.img-wrapper {
display: flex;
align-items: center;

View File

@ -34,6 +34,11 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.main.ui.notifications.context-notification :refer [context-notification]]
@ -126,15 +131,12 @@
[:li {:class (when settings-section? (stl/css :active))}
[:a {:on-click on-nav-settings} (tr "labels.settings")]]]]
[:div {:class (stl/css :dashboard-buttons)}
(if (and (or invitations-section? members-section?) (not-empty invitations))
[:button
{:class (stl/css :btn-secondary :btn-small)
:type "button"
:disabled (not can-invite?)
:on-click on-invite-member
:data-testid "invite-member"}
(tr "dashboard.invite-profile")]
[:div {:class (stl/css :blank-space)}])]]))
(when (and (or invitations-section? members-section?) (not-empty invitations))
[:> button* {:variant "secondary"
:on-click on-invite-member
:disabled (not can-invite?)
:data-testid "invite-member"}
(tr "dashboard.invite-profile")])]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INVITATIONS MODAL
@ -142,10 +144,10 @@
(defn get-available-roles
[permissions]
(->> [{:value "viewer" :label (tr "labels.viewer")}
{:value "editor" :label (tr "labels.editor")}
(->> [{:id "viewer" :value "viewer" :label (tr "labels.viewer")}
{:id "editor" :value "editor" :label (tr "labels.editor")}
(when (:is-admin permissions)
{:value "admin" :label (tr "labels.admin")})]
{:id "admin" :value "admin" :label (tr "labels.admin")})]
(filterv identity)))
(def ^:private schema:invite-member-form
@ -236,46 +238,55 @@
:on-error (partial on-error form)}]
(st/emit! (dtm/check-and-submit-invite-members (with-meta params mdata) origin do-invite-members!))))]
(mf/with-effect [team-id]
(st/emit! (dtm/fetch-members team-id)))
[:div {:class (stl/css-case :modal-team-container true
:modal-team-container-workspace (= origin :workspace)
:hero (= origin :hero))}
[:& fm/form {:on-submit on-submit :form form}
[:div {:class (stl/css :modal-title)}
[:> fc/form* {:form form
:class (stl/css :form-wrapper)
:on-submit on-submit}
[:> heading* {:level 2
:typography t/headline-medium
:class (stl/css :color-light)}
(tr "modals.invite-team-member.title")]
(when (= :workspace origin)
[:div {:class (stl/css :invite-team-member-text)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :color-light)}
(tr "modals.invite-team-member.text")])
(when-not (= "" @error-text)
[:& context-notification {:content @error-text
:level :error}])
[:> context-notification* {:level :error}
@error-text])
(when (some current-data-emails current-members-emails)
[:& context-notification {:content (tr "modals.invite-member.repeated-invitation")
:level :warning}])
[:> context-notification* {:level :warning}
(tr "modals.invite-member.repeated-invitation")])
[:div {:class (stl/css :role-select)}
[:p {:class (stl/css :role-title)}
[:div {:class (stl/css :form-group)}
[:> text* {:as "label"
:typography t/body-medium
:class (stl/css :color-light)}
(tr "onboarding.choice.team-up.roles")]
[:& fm/select {:name :role :options roles}]]
[:> fc/form-select* {:name :role
:default-selected "editor"
:options roles}]
[:> fc/form-multi-input* {:type "email"
:name :emails
:auto-focus? true
:trim true
:valid-item-fn sm/parse-email
:caution-item-fn current-members-emails
:placeholder (tr "modals.invite-member.emails")}]]
[:div {:class (stl/css :invitation-row)}
[:& fm/multi-input {:type "email"
:class (stl/css :email-input)
:name :emails
:auto-focus? true
:trim true
:valid-item-fn sm/parse-email
:caution-item-fn current-members-emails
:label (tr "modals.invite-member.emails")}]]
[:div {:class (stl/css :action-buttons)}
[:> fm/submit-button*
{:label (tr "modals.invite-member-confirm.accept")
:class (stl/css :accept-btn)
:disabled (and (boolean (some current-data-emails current-members-emails))
(empty? (remove current-members-emails current-data-emails)))}]]]]))
[:div {:class (stl/css :form-buttons)}
[:> fc/form-submit*
{:disabled (and (boolean (some current-data-emails current-members-emails))
(empty? (remove current-members-emails current-data-emails)))}
(tr "modals.invite-member-confirm.accept")]]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INVITE RESTRICTED MEMBERS MODAL
@ -821,15 +832,13 @@
[:div {:class (stl/css :empty-invitations)}
[:div (tr "labels.no-invitations")]
(if ^boolean can-invite
[[:div (tr "labels.no-invitations-gather-people")]
[:div {:class (stl/css :empty-invitations-buttons)}
[:a
{:class (stl/css :btn-empty-invitations)
:role "button"
:on-click on-invite-member
:data-testid "invite-member"}
(tr "dashboard.invite-profile")]]
[:div {:class (stl/css :blank-space)}]]
[:*
[:div (tr "labels.no-invitations-gather-people")]
[:> button* {:variant "primary"
:class (stl/css :btn-empty-invitations)
:on-click on-invite-member
:data-testid "invite-member"}
(tr "dashboard.invite-profile")]]
[:div {:class (stl/css :no-permission-text)} (tr "dashboard.invitations.no-permission")])]))
(mf/defc invitation-modal

View File

@ -17,7 +17,7 @@
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
inline-size: 100%;
border-top: $b-1 solid var(--color-background-quaternary);
overflow-y: auto;
padding-inline-start: var(--sp-xl);
@ -35,8 +35,8 @@
display: grid;
grid-auto-rows: min-content;
gap: var(--sp-s);
max-width: $sz-1000;
width: 100%;
max-inline-size: $sz-1000;
inline-size: 100%;
}
.info-block {
@ -62,8 +62,8 @@
}
.owner-icon {
width: $sz-32;
height: $sz-32;
inline-size: $sz-32;
block-size: $sz-32;
border-radius: 50%;
}
@ -73,8 +73,8 @@
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
@ -86,8 +86,8 @@
--update-button-opacity: 0;
position: relative;
height: $sz-120;
width: $sz-120;
block-size: $sz-120;
inline-size: $sz-120;
padding: var(--sp-l);
margin-block-end: var(--sp-xxxl);
@ -101,8 +101,8 @@
top: 0;
left: 0;
border-radius: 50%;
width: $sz-120;
height: $sz-120;
inline-size: $sz-120;
block-size: $sz-120;
}
.update-overlay {
@ -116,8 +116,8 @@
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
block-size: 100%;
inline-size: 100%;
z-index: var(--z-index-set);
border-radius: $br-circle;
background-color: var(--color-accent-primary);
@ -127,13 +127,13 @@
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
min-width: $sz-24;
min-height: $sz-24;
min-inline-size: $sz-24;
min-block-size: $sz-24;
stroke: var(--color-foreground-primary);
}
@ -141,8 +141,8 @@
.dashboard-team-members {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
inline-size: 100%;
block-size: 100%;
padding-inline-start: var(--sp-xl);
padding-block-start: var(--sp-xl);
border-top: $b-1 solid var(--color-background-quaternary);
@ -158,9 +158,9 @@
.team-members {
display: grid;
grid-template-rows: auto 1fr;
height: fit-content;
max-width: $sz-1000;
width: 100%;
block-size: fit-content;
max-inline-size: $sz-1000;
inline-size: 100%;
}
.table-header {
@ -169,9 +169,9 @@
display: grid;
align-items: center;
grid-template-columns: 43% 1fr px2rem(108) var(--sp-m);
height: $sz-40;
width: 100%;
max-width: $sz-1000;
block-size: $sz-40;
inline-size: 100%;
max-inline-size: $sz-1000;
padding: 0 var(--sp-l);
user-select: none;
color: var(--color-foreground-secondary);
@ -181,9 +181,9 @@
display: grid;
grid-auto-rows: px2rem(64);
gap: var(--sp-l);
width: 100%;
height: 100%;
max-width: $sz-1000;
inline-size: 100%;
block-size: 100%;
max-inline-size: $sz-1000;
margin-top: var(--sp-l);
color: var(--color-foreground-secondary);
}
@ -192,8 +192,8 @@
display: grid;
grid-template-columns: 43% 1fr auto;
align-items: center;
height: px2rem(64);
width: 100%;
block-size: px2rem(64);
inline-size: 100%;
padding: 0 var(--sp-l);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
@ -201,8 +201,8 @@
}
.title-field-name {
width: 43%;
min-width: px2rem(300);
inline-size: 43%;
min-inline-size: px2rem(300);
}
.title-field-role {
@ -221,8 +221,8 @@
display: grid;
grid-template-columns: auto 1fr;
gap: var(--sp-l);
width: 43%;
min-width: px2rem(300);
inline-size: 43%;
min-inline-size: px2rem(300);
}
.field-roles {
@ -236,15 +236,15 @@
// MEMBER INFO
.member-image {
height: $sz-32;
width: $sz-32;
block-size: $sz-32;
inline-size: $sz-32;
border-radius: $br-circle;
}
.member-info {
display: grid;
grid-template-rows: 1fr 1fr;
width: 100%;
inline-size: 100%;
}
.member-name,
@ -272,9 +272,9 @@
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: $sz-32;
min-width: $sz-160;
width: fit-content;
block-size: $sz-32;
min-inline-size: $sz-160;
inline-size: fit-content;
padding: var(--sp-xs) var(--sp-s);
border-radius: $br-8;
border-color: var(--color-background-quaternary);
@ -303,8 +303,8 @@
border: $b-2 solid var(--color-background-quaternary);
margin: 0;
bottom: calc(-1 * px2rem(76));
width: fit-content;
min-width: $sz-160;
inline-size: fit-content;
min-inline-size: $sz-160;
}
.rol-dropdown-item {
@ -313,8 +313,8 @@
display: flex;
align-items: center;
justify-content: space-between;
height: $sz-28;
width: 100%;
block-size: $sz-28;
inline-size: 100%;
padding: px2rem(6);
border-radius: $br-8;
cursor: pointer;
@ -329,8 +329,8 @@
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
@ -366,8 +366,8 @@
bottom: calc(-1 * var(--sp-xxxl));
right: 0;
left: unset;
width: fit-content;
min-width: $sz-160;
inline-size: fit-content;
min-inline-size: $sz-160;
}
.action-dropdown-item {
@ -376,8 +376,8 @@
display: flex;
align-items: center;
justify-content: space-between;
height: $sz-28;
width: 100%;
block-size: $sz-28;
inline-size: 100%;
padding: px2rem(6);
border-radius: $br-8;
cursor: pointer;
@ -391,8 +391,8 @@
.dashboard-team-invitations {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
inline-size: 100%;
block-size: 100%;
padding-inline-start: var(--sp-xl);
padding-block-start: var(--sp-xl);
border-top: $b-1 solid var(--color-background-quaternary);
@ -403,9 +403,9 @@
.invitations {
display: grid;
grid-template-rows: auto 1fr;
height: fit-content;
max-width: $sz-1000;
width: 100%;
block-size: fit-content;
max-inline-size: $sz-1000;
inline-size: 100%;
}
.invitations-actions {
@ -416,7 +416,7 @@
align-items: center;
gap: var(--sp-l);
color: var(--title-foreground-color);
height: $sz-40;
block-size: $sz-40;
margin-block-end: px2rem(36);
}
@ -430,7 +430,7 @@
}
.empty-invitations {
width: 100%;
inline-size: 100%;
margin-top: var(--sp-l);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
@ -440,21 +440,17 @@
}
.empty-invitations-buttons {
width: fit-content;
inline-size: fit-content;
margin: auto;
}
.no-permission-text {
max-width: $sz-512;
max-inline-size: $sz-512;
margin: auto;
}
.btn-empty-invitations {
// TODO: Remove this extend add DS component
@extend %button-primary;
margin-block-start: var(--sp-l);
padding-inline: var(--sp-m);
}
.title-field-status {
@ -490,8 +486,8 @@
grid-template-rows: auto 1fr;
justify-items: center;
gap: var(--sp-xxl);
width: 100%;
height: 100%;
inline-size: 100%;
block-size: 100%;
padding-inline-start: var(--sp-xl);
padding-block-start: var(--sp-xl);
border-top: $b-1 solid var(--color-background-quaternary);
@ -509,9 +505,9 @@
display: grid;
place-items: center;
align-content: center;
height: px2rem(156);
max-width: $sz-1000;
width: 100%;
block-size: px2rem(156);
max-inline-size: $sz-1000;
inline-size: 100%;
padding: var(--sp-xxxl);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
@ -527,7 +523,7 @@
margin: 0;
padding: var(--sp-xxxl);
padding: 0;
width: px2rem(468);
inline-size: px2rem(468);
}
.hero-title {
@ -541,19 +537,19 @@
color: var(--color-foreground-secondary);
margin-bottom: 0;
max-width: $sz-512;
max-inline-size: $sz-512;
}
.hero-btn {
// TODO: Remove this extended class using a DS component
@extend %button-primary;
height: $sz-32;
max-width: $sz-512;
block-size: $sz-32;
max-inline-size: $sz-512;
}
.webhook-table {
height: fit-content;
block-size: fit-content;
}
.webhook-row {
@ -569,7 +565,7 @@
.menu-disabled {
color: var(--color-foreground-secondary);
width: $sz-28;
inline-size: $sz-28;
display: flex;
justify-content: center;
align-items: center;
@ -590,8 +586,8 @@
margin: 0;
right: calc(-1 * var(--sp-l));
bottom: calc(-1 * $sz-40);
width: fit-content;
min-width: $sz-160;
inline-size: fit-content;
min-inline-size: $sz-160;
}
.webhook-dropdown-item {
@ -600,8 +596,8 @@
display: flex;
align-items: center;
justify-content: space-between;
height: $sz-28;
width: 100%;
block-size: $sz-28;
inline-size: 100%;
padding: px2rem(6);
border-radius: $br-8;
cursor: pointer;
@ -615,8 +611,8 @@
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
@ -627,8 +623,8 @@
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
@ -639,16 +635,16 @@
.modal-team-container {
border-radius: $br-8;
border: $b-2 solid var(--color-background-quaternary);
min-width: $sz-364;
min-height: $sz-192;
max-width: $sz-512;
max-height: $sz-512;
min-inline-size: $sz-364;
min-block-size: $sz-192;
max-inline-size: $sz-512;
max-block-size: $sz-512;
box-shadow: var(--el-shadow-dark);
position: fixed;
top: px2rem(72);
right: var(--sp-m);
left: unset;
width: $sz-400;
inline-size: $sz-400;
padding: var(--sp-xxxl);
background-color: var(--color-background-primary);
z-index: var(--z-index-set);
@ -664,26 +660,33 @@
z-index: var(--z-index-set);
}
.modal-title {
@include t.use-typography("headline-medium");
height: $sz-32;
.color-light {
color: var(--color-foreground-primary);
}
.role-select {
.form-wrapper {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
row-gap: var(--sp-s);
gap: var(--sp-xl);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.form-buttons {
display: flex;
justify-content: flex-end;
}
.arrow-icon {
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
@ -691,25 +694,6 @@
transform: rotate(90deg);
}
.invite-team-member-text {
@include t.use-typography("body-large");
margin: 0 0 var(--sp-l) 0;
color: var(--color-foreground-primary);
}
.role-title {
@include t.use-typography("body-large");
margin: 0;
color: var(--color-foreground-primary);
}
.invitation-row {
margin-top: var(--sp-s);
margin-bottom: var(--sp-xxl);
}
.action-buttons {
display: flex;
justify-content: flex-end;
@ -729,8 +713,8 @@
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 100%;
block-size: 100%;
inline-size: 100%;
z-index: var(--z-index-set);
background-color: var(--overlay-color);
}
@ -741,10 +725,10 @@
border-radius: $br-8;
background-color: var(--color-background-primary);
border: $b-2 solid var(--color-background-quaternary);
min-width: $sz-364;
min-height: $sz-192;
max-width: $sz-512;
max-height: $sz-512;
min-inline-size: $sz-364;
min-block-size: $sz-192;
max-inline-size: $sz-512;
max-block-size: $sz-512;
}
.modal-header {
@ -795,23 +779,11 @@
.action-buttons {
@extend %modal-action-btns;
button {
@extend %modal-accept-btn;
}
.cancel-button {
@extend %modal-cancel-btn;
}
}
// TODO: Remove this extended class using input component
.email-input {
@include t.use-typography("body-small");
@extend %input-base;
height: auto;
}
// FIXME: This does not conform to our CSS Guidelines. Need to unnest and to use
// custom properties to handle state changes.
.input-wrapper {
@ -963,7 +935,7 @@
.modal-select-org-container {
display: flex;
flex-direction: column;
width: $sz-512;
inline-size: $sz-512;
}
.modal-select-org-header {
@ -1017,7 +989,7 @@
grid-template-columns: var(--sp-xxxl) 1fr var(--sp-xxxl);
align-items: center;
gap: var(--sp-m);
width: max-content;
inline-size: max-content;
}
.org-options-btn {
@ -1045,8 +1017,8 @@
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
block-size: $sz-16;
inline-size: $sz-16;
color: transparent;
fill: none;
stroke-width: $b-1;
@ -1067,8 +1039,8 @@
border: $b-2 solid var(--color-background-quaternary);
margin: 0;
top: var(--sp-xxxl);
width: fit-content;
min-width: $sz-160;
inline-size: fit-content;
min-inline-size: $sz-160;
}
.org-dropdown-item {
@ -1077,8 +1049,8 @@
display: flex;
align-items: center;
justify-content: space-between;
height: $sz-28;
width: 100%;
block-size: $sz-28;
inline-size: 100%;
padding: px2rem(6);
border-radius: $br-8;
cursor: pointer;

View File

@ -66,57 +66,53 @@
touched? (and (contains? (:data @form) name)
(get-in @form [:touched name]))
value (mf/use-state "")
focus? (mf/use-state false)
value* (mf/use-state "")
value (deref value*)
items
(mf/use-state
(fn []
(let [initial (get-in @form [:data name])]
(if (or (vector? initial) (set? initial))
(mapv (fn [val]
{:text val
:valid (valid-item-fn val)
:caution (caution-item-fn val)})
initial)
[]))))
on-focus
(mf/use-fn
#(reset! focus? true))
items* (mf/use-state
(fn []
(let [initial (get-in @form [:data name])]
(if (or (vector? initial) (set? initial))
(mapv (fn [val]
{:text val
:valid (valid-item-fn val)
:caution (caution-item-fn val)})
initial)
[]))))
items (deref items*)
on-change
(mf/use-fn
(fn [event]
(let [content (-> event dom/get-target dom/get-input-value)]
(reset! value content))))
(reset! value* content))))
on-key-down
(mf/use-fn
(mf/deps @value form name valid-item-fn caution-item-fn trim)
(mf/deps value form name valid-item-fn caution-item-fn trim)
(fn [event]
(let [val (cond-> @value trim str/trim)]
(let [val (cond-> value trim str/trim)]
(cond
(or (k/enter? event) (k/comma? event) (k/space? event))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(swap! form assoc-in [:touched name] true)
(when (and (valid-item-fn val) (not (str/empty? @value)))
(reset! value "")
(when (and (valid-item-fn val) (not (str/empty? value)))
(reset! value* "")
(swap! form assoc-in [:touched name] false)
(doseq [v (str/split val #",|\s+")]
(let [v (str/trim v)]
(swap! items conj-dedup {:text v
:valid (valid-item-fn v)
:caution (caution-item-fn v)})))))
(swap! items* conj-dedup {:text v
:valid (valid-item-fn v)
:caution (caution-item-fn v)})))))
(and (k/backspace? event) (str/empty? @value))
(and (k/backspace? event) (str/empty? value))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(swap! items (fn [items]
(if (empty? items) items (pop items)))))))))
(swap! items* (fn [items]
(if (empty? items) items (pop items)))))))))
on-paste
(mf/use-fn
@ -136,43 +132,41 @@
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
(swap! items* conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(reset! value* "")
(swap! form assoc-in [:touched name] false))))))
on-blur
(mf/use-fn
(fn []
(reset! focus? false)
(when-not (get-in @form [:touched name])
(swap! form assoc-in [:touched name] true))))
on-remove-item
(mf/use-fn
(fn [item]
(swap! items #(filterv (fn [x] (not= x item)) %))))
(swap! items* #(filterv (fn [x] (not= x item)) %))))
props
(mf/spread-props props {:value @value
(mf/spread-props props {:value value
:on-change on-change
:on-focus on-focus
:on-blur on-blur
:on-key-down on-key-down
:on-paste on-paste
:hint-type (when (and touched?
(not (str/empty? @value))
(not (valid-item-fn @value))) "error")})]
(not (str/empty? value))
(not (valid-item-fn value))) "error")})]
;; Sync form data whenever items or input value changes.
;; This ensures the current (unconfirmed) input value is included in the
;; form data when the user submits without pressing Enter/Space/Comma.
(mf/with-effect [@items @value]
(let [items-text (mapv :text @items)
val (cond-> @value trim str/trim)
(mf/with-effect [items value]
(let [items-text (mapv :text items)
val (cond-> value trim str/trim)
combined (if (and (valid-item-fn val) (not (str/empty? val)))
(conj items-text val)
items-text)
@ -182,7 +176,7 @@
[:div {:class (stl/css :multi-input)}
[:> input* props]
(when-let [items-seq (seq @items)]
(when-let [items-seq (seq items)]
[:div {:class (stl/css :multi-input-chips)}
(for [item items-seq]
[:div {:class (stl/css :multi-input-chip)