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 e751cb361..307d9dfa1 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -331,15 +331,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 @@ -350,14 +355,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 @@ -385,12 +392,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/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..5b5f4d14a 100644 --- a/frontend/resources/styles/main/partials/dashboard-team.scss +++ b/frontend/resources/styles/main/partials/dashboard-team.scss @@ -4,7 +4,7 @@ padding: 14px; box-shadow: 0px 4px 8px rgba($color-black, 0.25); border-radius: 8px; - width: 500px; + width: 450px; position: fixed; form { @@ -19,12 +19,30 @@ .custom-input { width: 314px; + height: 14px; + font-size: 14px; margin-right: 10px; + + input { + padding: 0; + + &.empty { + margin-top: 10px; + &::placeholder { + color: $color-gray-20; + opacity: 1; + } + } + &::placeholder { + color: transparent; + } + } } .custom-select { - width: 160px; + width: 155px; overflow: hidden; + justify-content: normal; } .action-buttons { @@ -38,6 +56,83 @@ .title { color: $color-black; } + + .hint { + font-size: 12px; + + &.hidden { + display: none; + } + } + + .invite-member-email-container { + border: 1px solid $color-black; + width: 273px; + margin-right: 10px; + max-height: 300px; + overflow-y: auto; + padding: 0 5px 5px 5px; + } + + .invite-member-email-text { + margin-bottom: 5px; + .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; + } + } + } + + .invite-member-email-input { + width: 95%; + border: 0; + } + + 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/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..ecd6b6180 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -11,6 +11,7 @@ [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] [clojure.string] [cuerdas.core :as str] @@ -74,7 +75,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 [_] @@ -141,7 +144,10 @@ ) 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 +183,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 +224,93 @@ (dom/prevent-default event) (on-submit form event))} children]])) + + + +(mf/defc multi-input-row + [{:keys [item, remove-item!, class, invalid-class]}] + (let [valid (val item) + text (key item)] + [:div {:class class} + [:span.around {:class (when-not valid invalid-class)} + [:span.text text] + [:span.icon {:on-click #(remove-item! (key item))} i/cross]]])) + +(mf/defc multi-input + [{:keys [form hint class container-class row-class row-invalid-class] :as props}] + (let [multi-input-name (get props :name) + single-input-name (keyword (str "single-" (name multi-input-name))) + single-input-element (dom/get-element (name single-input-name)) + hint-element (dom/get-element-by-class "hint") + form (or form (mf/use-ctx form-ctx)) + value (get-in @form [:data multi-input-name] "") + single-mail-value (get-in @form [:data single-input-name] "") + items (mf/use-state {}) + + comma-items + (fn [items] + (if (= "" single-mail-value) + (str/join "," (keys items)) + (str/join "," (conj (keys items) single-mail-value)))) + + update-multi-input + (fn [all] + (fm/on-input-change form multi-input-name all true) + + (if (= "" all) + (do + (dom/add-class! single-input-element "empty") + (dom/add-class! hint-element "hidden")) + (do + (dom/remove-class! single-input-element "empty") + (dom/remove-class! hint-element "hidden"))) + + (dom/focus! single-input-element)) + + remove-item! + (fn [item] + (swap! items + (fn [items] + (let [temp-items (dissoc items item) + all (comma-items temp-items)] + (update-multi-input all) + temp-items)))) + + add-item! + (fn [item valid] + (swap! items assoc item valid)) + + input-key-down (fn [event] + (let [target (dom/event->target event) + value (dom/get-value target) + valid (and (not (= value "")) (dom/valid? target))] + + (when (kbd/comma? event) + (dom/prevent-default event) + (add-item! value valid) + (fm/on-input-change form single-input-name "")))) + + input-key-up #(update-multi-input (comma-items @items)) + + single-props (-> props + (dissoc :hint :row-class :row-invalid-class :container-class :class) + (assoc + :label hint + :name single-input-name + :on-key-down input-key-down + :on-key-up input-key-up + :class (str/join " " [class "empty"])))] + + [:div {:class container-class} + (when (string? hint) + [:span.hint.hidden hint]) + (for [item @items] + [:& multi-input-row {:item item + :remove-item! remove-item! + :class row-class + :invalid-class row-invalid-class}]) + [:& input single-props] + [:input {:id (name multi-input-name) + :read-only true + :type "hidden" + :value value}]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index bc53a9840..8d2a542cf 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) + (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) - (= :member-is-muted code)) - (dm/error (tr "errors.member-is-muted")) - - (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] @@ -123,10 +123,23 @@ [:& 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/multi-input {:type "email" + :name :emails + :auto-focus? true + :hint (tr "modals.invite-member.emails") + :class "invite-member-email-input" + :container-class "invite-member-email-container" + :row-class "invite-member-email-text" + :row-invalid-class "invalid"}] [:& fm/select {:name :role :options roles}]] 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..4cf1ef87a 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,13 @@ (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 on-input-blur [form field] diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs index 0fb84f09d..2cf63ac24 100644 --- a/frontend/src/app/util/keyboard.cljs +++ b/frontend/src/app/util/keyboard.cljs @@ -35,6 +35,7 @@ (def altKey? (is-key? "Alt")) (def ctrlKey? (or (is-key? "Control") (is-key? "Meta"))) +(def comma? (is-key? ",")) (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 6fb722d97..f0bcd24b5 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"