mirror of
https://github.com/penpot/penpot.git
synced 2026-07-03 21:05:25 +00:00
✨ Improve dashboard invitations modal (#10459)
This commit is contained in:
parent
841b47736b
commit
2c4a9e0f82
@ -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]]])])]))
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user