diff --git a/CHANGES.md b/CHANGES.md index 2c9e783ea..87d1a845c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ ### :sparkles: New features - 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) - 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) diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj index 6380b7d28..268983250 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -354,15 +354,20 @@ (declare create-team-invitation) (s/def ::email ::us/email) +(s/def ::emails ::us/set-of-emails) (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 - [{: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] (let [perms (teams/get-permissions conn profile-id team-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) (ex/raise :type :validation @@ -373,14 +378,16 @@ (ex/raise :type :validation :code :profile-is-muted :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) - - (create-team-invitation - (assoc cfg - :email email - :conn conn - :team team - :profile profile - :role role)) + + (doseq [email emails] + (create-team-invitation + (assoc cfg + :email email + :conn conn + :team team + :profile profile + :role role)) + ) nil))) (def sql:upsert-team-invitation @@ -408,12 +415,14 @@ (when (and member (not (eml/allow-send-emails? conn member))) (ex/raise :type :validation :code :member-is-muted + :email email :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. (when (eml/has-bounce-reports? conn email) (ex/raise :type :validation :code :email-has-permanent-bounces + :email email :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc index 15b0cd1e7..083011b7b 100644 --- a/common/src/app/common/spec.cljc +++ b/common/src/app/common/spec.cljc @@ -161,14 +161,14 @@ (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/conformer (fn [v] - (if (string? v) - (if-let [matches (re-seq email-re v)] - (first matches) - (do ::s/invalid)) - ::s/invalid)) + (or (parse-email v) ::s/invalid)) str)) (s/def ::set-of-emails diff --git a/frontend/resources/images/icons/cross.svg b/frontend/resources/images/icons/cross.svg new file mode 100644 index 000000000..aa1472308 --- /dev/null +++ b/frontend/resources/images/icons/cross.svg @@ -0,0 +1 @@ + diff --git a/frontend/resources/styles/main/partials/dashboard-team.scss b/frontend/resources/styles/main/partials/dashboard-team.scss index a9d1d5872..f018f95f5 100644 --- a/frontend/resources/styles/main/partials/dashboard-team.scss +++ b/frontend/resources/styles/main/partials/dashboard-team.scss @@ -23,8 +23,12 @@ } .custom-select { - width: 160px; + width: 180px; overflow: hidden; + justify-content: normal; + select { + height: auto; + } } .action-buttons { @@ -38,6 +42,42 @@ .title { 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, diff --git a/frontend/resources/styles/main/partials/forms.scss b/frontend/resources/styles/main/partials/forms.scss index d3f52b61e..ac3c4ea07 100644 --- a/frontend/resources/styles/main/partials/forms.scss +++ b/frontend/resources/styles/main/partials/forms.scss @@ -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 { display: flex; flex-direction: column; diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 4adcd3dcc..1a495e871 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -427,8 +427,8 @@ (rx/catch on-error)))))) (defn invite-team-member - [{:keys [email role] :as params}] - (us/assert ::us/email email) + [{:keys [emails role] :as params}] + (us/assert ::us/set-of-emails emails) (us/assert ::us/keyword role) (ptk/reify ::invite-team-member IDeref @@ -770,7 +770,6 @@ (rx/tap on-success) (rx/catch on-error)))))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Navigation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -890,7 +889,7 @@ :team-id team-id}) action-name (if in-project? :create-file :create-project) action (if in-project? file-created project-created)] - + (->> (rp/mutation! action-name params) (rx/map action)))))) diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 54022b3fa..3c61dbce5 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -7,11 +7,14 @@ (ns app.main.ui.components.forms (:require [app.common.data :as d] + [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] [app.util.dom :as dom] [app.util.forms :as fm] [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as kbd] [app.util.object :as obj] + [cljs.core :as c] [clojure.string] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -74,7 +77,9 @@ "password")))) 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 (fn [_] @@ -136,12 +141,15 @@ :focus @focus? :valid (and touched? (not error)) :invalid (and touched? error) - :disabled disabled + :disabled disabled) ;; :empty (str/empty? value) - ) + 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 (fn [_] @@ -177,7 +185,10 @@ form (or form (mf/use-ctx form-ctx)) value (or (get-in @form [:data input-name]) default) 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 [:select {:value value @@ -215,3 +226,114 @@ (dom/prevent-default event) (on-submit form event))} 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]])) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index bc53a9840..ca0e2502b 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -73,10 +73,10 @@ ] (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 ::invite-member-form - (s/keys :req-un [::role ::email])) + (s/keys :req-un [::role ::emails])) (mf/defc invite-member-modal {::mf/register modal/components @@ -87,29 +87,29 @@ initial (mf/use-memo (constantly {:role "editor"})) form (fm/use-form :spec ::invite-member-form :initial initial) + error-text (mf/use-state "") + on-success (st/emitf (dm/success (tr "notifications.invitation-email-sent")) (modal/hide) (dd/fetch-team-invitations)) on-error - (fn [form {:keys [type code] :as error}] - (let [email (get @form [:data :email])] - (cond - (and (= :validation type) - (= :profile-is-muted code)) - (dm/error (tr "errors.profile-is-muted")) + (fn [{:keys [type code] :as error}] + (cond + (and (= :validation type) + (= :profile-is-muted code)) + (st/emit! (dm/error (tr "errors.profile-is-muted")) + (modal/hide)) - (and (= :validation type) - (= :member-is-muted code)) - (dm/error (tr "errors.member-is-muted")) + (and (= :validation type) + (or (= :member-is-muted code) + (= :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 - (dm/error (tr "errors.generic"))))) + :else + (st/emit! (dm/error (tr "errors.generic")) + (modal/hide)))) on-submit (fn [form] @@ -118,17 +118,25 @@ :on-error (partial on-error form)}] (st/emit! (dd/invite-team-member (with-meta params mdata)) (dd/fetch-team-invitations))))] - + [:div.modal.dashboard-invite-modal.form-container [:& fm/form {:on-submit on-submit :form form} [:div.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 - [:& fm/input {:name :email - :label (tr "labels.email")}] - [:& fm/select {:name :role - :options roles}]] + [:& fm/multi-input {:type "email" + :name :emails + :auto-focus? true + :trim true + :valid-item-fn us/parse-email + :label (tr "modals.invite-member.emails")}] + [:& fm/select {:name :role :options roles}]] [:div.action-buttons [:& fm/submit-button {:label (tr "modals.invite-member-confirm.accept")}]]]])) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index e2d61a77b..db8a4ead5 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -45,6 +45,7 @@ (def component (icon-xref :component)) (def copy (icon-xref :copy)) (def curve (icon-xref :curve)) +(def cross (icon-xref :cross)) (def download (icon-xref :download)) (def easing-linear (icon-xref :easing-linear)) (def easing-ease (icon-xref :easing-ease)) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index b8af3ed89..dac8dffc0 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -108,6 +108,15 @@ (when (some? 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 "Extract the value of one attribute of a dom node." [^js node ^string attr-name] diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 177770bc0..5593f6a2f 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -8,7 +8,6 @@ (:refer-clojure :exclude [uuid]) (:require [app.common.spec :as us] - [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cljs.spec.alpha :as s] [cuerdas.core :as str] @@ -114,19 +113,20 @@ (render inc))))) (defn on-input-change - ([form field] - (on-input-change form field false)) - ([form field 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] - (-> state - (assoc-in [:data field] (if trim? (str/trim value) value)) - (update :errors dissoc field)))))))) + ([form field value] + (on-input-change form field value false)) + ([form field value trim?] + (swap! form (fn [state] + (-> state + (assoc-in [:data field] (if trim? (str/trim value) value)) + (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 [form field] diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs index 0fb84f09d..eea236f83 100644 --- a/frontend/src/app/util/keyboard.cljs +++ b/frontend/src/app/util/keyboard.cljs @@ -35,6 +35,8 @@ (def altKey? (is-key? "Alt")) (def ctrlKey? (or (is-key? "Control") (is-key? "Meta"))) +(def comma? (is-key? ",")) +(def backspace? (is-key? "Backspace")) (defn editing? [e] (.-editing ^js e)) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fae9662a4..ad34b7d63 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -617,6 +617,9 @@ msgstr "You can't use your email as password" msgid "errors.email-has-permanent-bounces" 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 msgid "errors.email-invalid-confirmation" 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" msgstr "Delete team member" +msgid "modals.invite-member.emails" +msgstr "Emails, comma separated" + #: src/app/main/ui/dashboard/team.cljs msgid "modals.invite-member-confirm.accept" msgstr "Send invitation" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 004ceffa3..2483631d7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -620,6 +620,9 @@ msgstr "No puedes usar tu email como password" msgid "errors.email-has-permanent-bounces" 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 msgid "errors.email-invalid-confirmation" 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" msgstr "Eliminar integrante del equipo" +msgid "modals.invite-member.emails" +msgstr "Emails, separados por coma" + #: src/app/main/ui/dashboard/team.cljs msgid "modals.invite-member-confirm.accept" msgstr "Enviar invitacion"