Merge pull request #1655 from penpot/multiple-members-invitations

 Allow send multiple team invitations at once
This commit is contained in:
Andrey Antukh 2022-03-04 15:20:51 +01:00 committed by GitHub
commit 192b9213ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 326 additions and 62 deletions

View file

@ -6,6 +6,7 @@
### :sparkles: New features ### :sparkles: New features
- Add border radius to our artboars [Taiga #2056](https://tree.taiga.io/project/penpot/us/2056) - Add border radius to our artboars [Taiga #2056](https://tree.taiga.io/project/penpot/us/2056)
- Allow send multiple team invitations at once [Taiga #2798](https://tree.taiga.io/project/penpot/us/2798)
- Persist color palette and color picker across refresh [Taiga #1660](https://tree.taiga.io/project/penpot/issue/1660) - Persist color palette and color picker across refresh [Taiga #1660](https://tree.taiga.io/project/penpot/issue/1660)
- Ability to add multiple strokes to a shape [Taiga #2778](https://tree.taiga.io/project/penpot/us/2778) - Ability to add multiple strokes to a shape [Taiga #2778](https://tree.taiga.io/project/penpot/us/2778)
- Scroll to selected size in font size selector [Taiga #2825](https://tree.taiga.io/project/penpot/us/2825) - Scroll to selected size in font size selector [Taiga #2825](https://tree.taiga.io/project/penpot/us/2825)

View file

@ -354,15 +354,20 @@
(declare create-team-invitation) (declare create-team-invitation)
(s/def ::email ::us/email) (s/def ::email ::us/email)
(s/def ::emails ::us/set-of-emails)
(s/def ::invite-team-member (s/def ::invite-team-member
(s/keys :req-un [::profile-id ::team-id ::email ::role])) (s/keys :req-un [::profile-id ::team-id ::role]
:opt-un [::email ::emails]))
(sv/defmethod ::invite-team-member (sv/defmethod ::invite-team-member
[{:keys [pool] :as cfg} {:keys [profile-id team-id email role] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id team-id email emails role] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [perms (teams/get-permissions conn profile-id team-id) (let [perms (teams/get-permissions conn profile-id team-id)
profile (db/get-by-id conn :profile profile-id) profile (db/get-by-id conn :profile profile-id)
team (db/get-by-id conn :team team-id)] team (db/get-by-id conn :team team-id)
emails (or emails #{})
emails (if email (conj emails email) emails)
]
(when-not (:is-admin perms) (when-not (:is-admin perms)
(ex/raise :type :validation (ex/raise :type :validation
@ -374,6 +379,7 @@
:code :profile-is-muted :code :profile-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) :hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
(doseq [email emails]
(create-team-invitation (create-team-invitation
(assoc cfg (assoc cfg
:email email :email email
@ -381,6 +387,7 @@
:team team :team team
:profile profile :profile profile
:role role)) :role role))
)
nil))) nil)))
(def sql:upsert-team-invitation (def sql:upsert-team-invitation
@ -408,12 +415,14 @@
(when (and member (not (eml/allow-send-emails? conn member))) (when (and member (not (eml/allow-send-emails? conn member)))
(ex/raise :type :validation (ex/raise :type :validation
:code :member-is-muted :code :member-is-muted
:email email
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) :hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
;; Secondly check if the invited member email is part of the global spam/bounce report. ;; Secondly check if the invited member email is part of the global spam/bounce report.
(when (eml/has-bounce-reports? conn email) (when (eml/has-bounce-reports? conn email)
(ex/raise :type :validation (ex/raise :type :validation
:code :email-has-permanent-bounces :code :email-has-permanent-bounces
:email email
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))

View file

@ -161,14 +161,14 @@
(def email-re #"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") (def email-re #"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
(defn parse-email
[s]
(some->> s (re-seq email-re) first))
(s/def ::email (s/def ::email
(s/conformer (s/conformer
(fn [v] (fn [v]
(if (string? v) (or (parse-email v) ::s/invalid))
(if-let [matches (re-seq email-re v)]
(first matches)
(do ::s/invalid))
::s/invalid))
str)) str))
(s/def ::set-of-emails (s/def ::set-of-emails

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="3742 2512 500 500"><path fill="#b1b2b5" d="m4203 2512-211 211-212-211-38 39 211 211-211 211 39 39 211-211 211 211 39-39-211-211 211-211z"/><path d="m3761 2992-19-19 106-106 106-105-106-106-106-105 19-19 19-19 106 105 106 106 105-106 106-105 19 19 19 19-105 106-106 105 106 106 105 106-19 19-19 19-105-106-106-106-105 106-106 105-20-19z"/></svg>

After

Width:  |  Height:  |  Size: 394 B

View file

@ -23,8 +23,12 @@
} }
.custom-select { .custom-select {
width: 160px; width: 180px;
overflow: hidden; overflow: hidden;
justify-content: normal;
select {
height: auto;
}
} }
.action-buttons { .action-buttons {
@ -38,6 +42,42 @@
.title { .title {
color: $color-black; color: $color-black;
} }
.hint {
font-size: 12px;
&.hidden {
display: none;
}
}
svg {
width: 12px;
height: 12px;
fill: $color-gray-20;
}
.error {
background-color: #ffd9e0;
width: 100%;
display: flex;
.icon {
background-color: $color-danger;
text-align: center;
padding: 5px;
svg {
fill: $color-white;
width: 20px;
height: 20px;
margin: 5px;
}
}
.text {
color: $color-black;
padding: 5px;
font-size: 12px;
}
}
} }
.dashboard-team-members, .dashboard-team-members,

View file

@ -227,6 +227,66 @@ textarea {
} }
} }
.custom-multi-input {
border-radius: 2px;
border: 1px solid $color-gray-20;
max-height: 300px;
overflow-y: auto;
&.invalid {
label {
color: unset;
}
}
input {
border: 0px;
&.no-padding {
padding-top: 0px;
}
}
.selected-items {
padding-top: 25px;
padding-left: 15px;
display: flex;
flex-wrap: wrap;
}
.selected-item {
width: 100%;
&:not(:last-child) {
margin-right: 3px;
}
.around {
border: 1px solid $color-gray-20;
padding-left: 5px;
border-radius: 4px;
&.invalid {
border: 1px solid $color-danger;
}
.text {
display: inline-block;
max-width: 85%;
overflow: hidden;
text-overflow: ellipsis;
line-height: 15px;
font-size: 14px;
color: $color-black;
}
.icon {
cursor: pointer;
margin-left: 10px;
margin-right: 5px;
}
}
}
}
.custom-select { .custom-select {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -427,8 +427,8 @@
(rx/catch on-error)))))) (rx/catch on-error))))))
(defn invite-team-member (defn invite-team-member
[{:keys [email role] :as params}] [{:keys [emails role] :as params}]
(us/assert ::us/email email) (us/assert ::us/set-of-emails emails)
(us/assert ::us/keyword role) (us/assert ::us/keyword role)
(ptk/reify ::invite-team-member (ptk/reify ::invite-team-member
IDeref IDeref
@ -770,7 +770,6 @@
(rx/tap on-success) (rx/tap on-success)
(rx/catch on-error)))))) (rx/catch on-error))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Navigation ;; Navigation
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -7,11 +7,14 @@
(ns app.main.ui.components.forms (ns app.main.ui.components.forms
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm] [app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.object :as obj] [app.util.object :as obj]
[cljs.core :as c]
[clojure.string] [clojure.string]
[cuerdas.core :as str] [cuerdas.core :as str]
[rumext.alpha :as mf])) [rumext.alpha :as mf]))
@ -74,7 +77,9 @@
"password")))) "password"))))
on-focus #(reset! focus? true) on-focus #(reset! focus? true)
on-change (fm/on-input-change form input-name trim) on-change (fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(fm/on-input-change form input-name value trim)))
on-blur on-blur
(fn [_] (fn [_]
@ -136,12 +141,15 @@
:focus @focus? :focus @focus?
:valid (and touched? (not error)) :valid (and touched? (not error))
:invalid (and touched? error) :invalid (and touched? error)
:disabled disabled :disabled disabled)
;; :empty (str/empty? value) ;; :empty (str/empty? value)
)
on-focus #(reset! focus? true) on-focus #(reset! focus? true)
on-change (fm/on-input-change form input-name trim) on-change (fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)]
(fm/on-input-change form input-name value trim)))
on-blur on-blur
(fn [_] (fn [_]
@ -177,7 +185,10 @@
form (or form (mf/use-ctx form-ctx)) form (or form (mf/use-ctx form-ctx))
value (or (get-in @form [:data input-name]) default) value (or (get-in @form [:data input-name]) default)
cvalue (d/seek #(= value (:value %)) options) cvalue (d/seek #(= value (:value %)) options)
on-change (fm/on-input-change form input-name)] on-change (fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)]
(fm/on-input-change form input-name value)))]
[:div.custom-select [:div.custom-select
[:select {:value value [:select {:value value
@ -215,3 +226,114 @@
(dom/prevent-default event) (dom/prevent-default event)
(on-submit form event))} (on-submit form event))}
children]])) 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 name trim valid-item-fn] :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)
items (mf/use-state [])
value (mf/use-state "")
result (hooks/use-equal-memo @items)
empty? (and (str/empty? @value)
(zero? (count @items)))
klass (str (get props :class) " "
(dom/classnames
:focus @focus?
:valid (and touched? (not error))
:invalid (and touched? error)
:empty empty?
:custom-multi-input true
:custom-input true))
in-klass (str class " "
(dom/classnames
:no-padding (pos? (count @items))))
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]
(cond
(or (kbd/enter? event)
(kbd/comma? event))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(let [val (cond-> @value trim str/trim)]
(reset! value "")
(swap! items conj-dedup {:text val :valid (valid-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-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))) %))))]
(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}
(when-let [items (seq @items)]
[:div.selected-items
(for [item items]
[:div.selected-item {:key (:text item)}
[:span.around {:class (when-not (:valid item) "invalid")}
[:span.text (:text item)]
[:span.icon {:on-click #(remove-item! item)} i/cross]]])])
[:input {:id (name input-name)
:class in-klass
:type "text"
:auto-focus true
:on-focus on-focus
:on-blur on-blur
:on-key-down on-key-down
:value @value
:on-change on-change
:placeholder (when empty? label)}]
[:label {:for (name input-name)} label]]))

View file

@ -73,10 +73,10 @@
] ]
(filterv identity))) (filterv identity)))
(s/def ::email ::us/email) (s/def ::emails (s/and ::us/set-of-emails d/not-empty?))
(s/def ::role ::us/keyword) (s/def ::role ::us/keyword)
(s/def ::invite-member-form (s/def ::invite-member-form
(s/keys :req-un [::role ::email])) (s/keys :req-un [::role ::emails]))
(mf/defc invite-member-modal (mf/defc invite-member-modal
{::mf/register modal/components {::mf/register modal/components
@ -87,29 +87,29 @@
initial (mf/use-memo (constantly {:role "editor"})) initial (mf/use-memo (constantly {:role "editor"}))
form (fm/use-form :spec ::invite-member-form form (fm/use-form :spec ::invite-member-form
:initial initial) :initial initial)
error-text (mf/use-state "")
on-success on-success
(st/emitf (dm/success (tr "notifications.invitation-email-sent")) (st/emitf (dm/success (tr "notifications.invitation-email-sent"))
(modal/hide) (modal/hide)
(dd/fetch-team-invitations)) (dd/fetch-team-invitations))
on-error on-error
(fn [form {:keys [type code] :as error}] (fn [{:keys [type code] :as error}]
(let [email (get @form [:data :email])]
(cond (cond
(and (= :validation type) (and (= :validation type)
(= :profile-is-muted code)) (= :profile-is-muted code))
(dm/error (tr "errors.profile-is-muted")) (st/emit! (dm/error (tr "errors.profile-is-muted"))
(modal/hide))
(and (= :validation type) (and (= :validation type)
(= :member-is-muted code)) (or (= :member-is-muted code)
(dm/error (tr "errors.member-is-muted")) (= :email-has-permanent-bounces code)))
(swap! error-text (tr "errors.email-spam-or-permanent-bounces" (:email error)))
(and (= :validation type)
(= :email-has-permanent-bounces code))
(dm/error (tr "errors.email-has-permanent-bounces" email))
:else :else
(dm/error (tr "errors.generic"))))) (st/emit! (dm/error (tr "errors.generic"))
(modal/hide))))
on-submit on-submit
(fn [form] (fn [form]
@ -124,11 +124,19 @@
[:div.title [:div.title
[:span.text (tr "modals.invite-member.title")]] [:span.text (tr "modals.invite-member.title")]]
(when-not (= "" @error-text)
[:div.error
[:span.icon i/msg-error]
[:span.text @error-text]])
[:div.form-row [:div.form-row
[:& fm/input {:name :email [:& fm/multi-input {:type "email"
:label (tr "labels.email")}] :name :emails
[:& fm/select {:name :role :auto-focus? true
:options roles}]] :trim true
:valid-item-fn us/parse-email
:label (tr "modals.invite-member.emails")}]
[:& fm/select {:name :role :options roles}]]
[:div.action-buttons [:div.action-buttons
[:& fm/submit-button {:label (tr "modals.invite-member-confirm.accept")}]]]])) [:& fm/submit-button {:label (tr "modals.invite-member-confirm.accept")}]]]]))

View file

@ -45,6 +45,7 @@
(def component (icon-xref :component)) (def component (icon-xref :component))
(def copy (icon-xref :copy)) (def copy (icon-xref :copy))
(def curve (icon-xref :curve)) (def curve (icon-xref :curve))
(def cross (icon-xref :cross))
(def download (icon-xref :download)) (def download (icon-xref :download))
(def easing-linear (icon-xref :easing-linear)) (def easing-linear (icon-xref :easing-linear))
(def easing-ease (icon-xref :easing-ease)) (def easing-ease (icon-xref :easing-ease))

View file

@ -108,6 +108,15 @@
(when (some? node) (when (some? node)
(.-value node))) (.-value node)))
(defn get-input-value
"Extract the value from dom input node taking into account the type."
[^js node]
(when (some? node)
(if (or (= (.-type node) "checkbox")
(= (.-type node) "radio"))
(.-checked node)
(.-value node))))
(defn get-attribute (defn get-attribute
"Extract the value of one attribute of a dom node." "Extract the value of one attribute of a dom node."
[^js node ^string attr-name] [^js node ^string attr-name]

View file

@ -8,7 +8,6 @@
(:refer-clojure :exclude [uuid]) (:refer-clojure :exclude [uuid])
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[cljs.spec.alpha :as s] [cljs.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
@ -114,19 +113,20 @@
(render inc))))) (render inc)))))
(defn on-input-change (defn on-input-change
([form field] ([form field value]
(on-input-change form field false)) (on-input-change form field value false))
([form field trim?] ([form field value trim?]
(fn [event]
(let [target (dom/get-target event)
value (if (or (= (.-type target) "checkbox")
(= (.-type target) "radio"))
(.-checked target)
(dom/get-value target))]
(swap! form (fn [state] (swap! form (fn [state]
(-> state (-> state
(assoc-in [:data field] (if trim? (str/trim value) value)) (assoc-in [:data field] (if trim? (str/trim value) value))
(update :errors dissoc field)))))))) (update :errors dissoc field))))))
(defn update-input-value!
[form field value]
(swap! form (fn [state]
(-> state
(assoc-in [:data field] value)
(update :errors dissoc field)))))
(defn on-input-blur (defn on-input-blur
[form field] [form field]

View file

@ -35,6 +35,8 @@
(def altKey? (is-key? "Alt")) (def altKey? (is-key? "Alt"))
(def ctrlKey? (or (is-key? "Control") (def ctrlKey? (or (is-key? "Control")
(is-key? "Meta"))) (is-key? "Meta")))
(def comma? (is-key? ","))
(def backspace? (is-key? "Backspace"))
(defn editing? [e] (defn editing? [e]
(.-editing ^js e)) (.-editing ^js e))

View file

@ -617,6 +617,9 @@ msgstr "You can't use your email as password"
msgid "errors.email-has-permanent-bounces" msgid "errors.email-has-permanent-bounces"
msgstr "The email «%s» has many permanent bounce reports." msgstr "The email «%s» has many permanent bounce reports."
msgid "errors.email-spam-or-permanent-bounces"
msgstr "The email «%s» has been reported as spam or permanently bounce."
#: src/app/main/ui/settings/change_email.cljs #: src/app/main/ui/settings/change_email.cljs
msgid "errors.email-invalid-confirmation" msgid "errors.email-invalid-confirmation"
msgstr "Confirmation email must match" msgstr "Confirmation email must match"
@ -1551,6 +1554,9 @@ msgstr "Are you sure you want to delete this member from the team?"
msgid "modals.delete-team-member-confirm.title" msgid "modals.delete-team-member-confirm.title"
msgstr "Delete team member" msgstr "Delete team member"
msgid "modals.invite-member.emails"
msgstr "Emails, comma separated"
#: src/app/main/ui/dashboard/team.cljs #: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-member-confirm.accept" msgid "modals.invite-member-confirm.accept"
msgstr "Send invitation" msgstr "Send invitation"

View file

@ -620,6 +620,9 @@ msgstr "No puedes usar tu email como password"
msgid "errors.email-has-permanent-bounces" msgid "errors.email-has-permanent-bounces"
msgstr "El email «%s» tiene varios reportes de rebote permanente." msgstr "El email «%s» tiene varios reportes de rebote permanente."
msgid "errors.email-spam-or-permanent-bounces"
msgstr "El email «%s» tiene reportes de spam o de rebote permanente."
#: src/app/main/ui/settings/change_email.cljs #: src/app/main/ui/settings/change_email.cljs
msgid "errors.email-invalid-confirmation" msgid "errors.email-invalid-confirmation"
msgstr "El correo de confirmación debe coincidir" msgstr "El correo de confirmación debe coincidir"
@ -1553,6 +1556,9 @@ msgstr "¿Seguro que quieres eliminar este integrante del equipo?"
msgid "modals.delete-team-member-confirm.title" msgid "modals.delete-team-member-confirm.title"
msgstr "Eliminar integrante del equipo" msgstr "Eliminar integrante del equipo"
msgid "modals.invite-member.emails"
msgstr "Emails, separados por coma"
#: src/app/main/ui/dashboard/team.cljs #: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-member-confirm.accept" msgid "modals.invite-member-confirm.accept"
msgstr "Enviar invitacion" msgstr "Enviar invitacion"