mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 07:46:16 +00:00
🐛 Fix organization/team switcher issues from UX review (#11899)
* 🐛 Fix organization/team switcher issues from UX review * 🐛 Let members leave an organization without SSO credentials * 🐛 Fix style from previewed organization in the team switcher * 🐛 Fix leave-organization modal test stubs
This commit is contained in:
parent
dfd28b1e57
commit
f35e12f716
@ -143,6 +143,7 @@
|
|||||||
|
|
||||||
(def ^:private sql:get-member-teams-info
|
(def ^:private sql:get-member-teams-info
|
||||||
"SELECT t.id,
|
"SELECT t.id,
|
||||||
|
t.name,
|
||||||
t.is_default,
|
t.is_default,
|
||||||
tpr.is_owner,
|
tpr.is_owner,
|
||||||
(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members,
|
(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members,
|
||||||
@ -214,6 +215,19 @@
|
|||||||
[:teams-to-transfer ::sm/int]
|
[:teams-to-transfer ::sm/int]
|
||||||
[:teams-to-exit ::sm/int]
|
[:teams-to-exit ::sm/int]
|
||||||
[:teams-to-detach ::sm/int]
|
[:teams-to-detach ::sm/int]
|
||||||
|
[:team-ids-to-delete [:vector ::sm/uuid]]
|
||||||
|
[:transferable-teams
|
||||||
|
[:vector
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:members
|
||||||
|
[:vector
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name :string]
|
||||||
|
[:email ::sm/text]
|
||||||
|
[:is-admin ::sm/boolean]]]]]]]
|
||||||
[:member-added-at [:maybe ct/schema:inst]]
|
[:member-added-at [:maybe ct/schema:inst]]
|
||||||
[:organization-member-count-before ::sm/int]])
|
[:organization-member-count-before ::sm/int]])
|
||||||
|
|
||||||
@ -361,6 +375,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::leave-organization
|
(sv/defmethod ::leave-organization
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
|
::nitrate/sso false
|
||||||
::doc/added "2.18"
|
::doc/added "2.18"
|
||||||
::sm/params schema:leave-organization
|
::sm/params schema:leave-organization
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
@ -371,8 +386,21 @@
|
|||||||
:deleted-by-role "organization-member")))
|
:deleted-by-role "organization-member")))
|
||||||
|
|
||||||
|
|
||||||
|
(defn- get-transferable-teams
|
||||||
|
"`teams-to-transfer` with their members, except `profile-id`."
|
||||||
|
[{:keys [::db/conn]} teams-to-transfer profile-id]
|
||||||
|
(mapv (fn [{:keys [id name]}]
|
||||||
|
{:id id
|
||||||
|
:name name
|
||||||
|
:members (->> (teams/get-team-members conn id)
|
||||||
|
(remove #(= profile-id (:id %)))
|
||||||
|
(mapv #(select-keys % [:id :name :email :is-admin])))})
|
||||||
|
teams-to-transfer))
|
||||||
|
|
||||||
|
;; Not SSO gated, so members can leave without SSO credentials.
|
||||||
(sv/defmethod ::get-leave-organization-summary
|
(sv/defmethod ::get-leave-organization-summary
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
|
::nitrate/sso false
|
||||||
::doc/added "2.18"
|
::doc/added "2.18"
|
||||||
::sm/params schema:get-leave-organization-summary
|
::sm/params schema:get-leave-organization-summary
|
||||||
::sm/result schema:get-leave-organization-summary-result
|
::sm/result schema:get-leave-organization-summary-result
|
||||||
@ -394,6 +422,8 @@
|
|||||||
:code :not-valid-teams))
|
:code :not-valid-teams))
|
||||||
(assoc
|
(assoc
|
||||||
(get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
(get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
||||||
|
:team-ids-to-delete (vec valid-teams-to-delete-ids)
|
||||||
|
:transferable-teams (get-transferable-teams cfg valid-teams-to-transfer profile-id)
|
||||||
:member-added-at (:created-at membership)
|
:member-added-at (:created-at membership)
|
||||||
:organization-member-count-before (count organization-members))))
|
:organization-member-count-before (count organization-members))))
|
||||||
|
|
||||||
|
|||||||
@ -584,6 +584,8 @@
|
|||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 0
|
:teams-to-exit 0
|
||||||
:teams-to-detach 0
|
:teams-to-detach 0
|
||||||
|
:team-ids-to-delete []
|
||||||
|
:transferable-teams []
|
||||||
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||||
:organization-member-count-before 2}
|
:organization-member-count-before 2}
|
||||||
(:result out)))))))
|
(:result out)))))))
|
||||||
@ -618,10 +620,50 @@
|
|||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 0
|
:teams-to-exit 0
|
||||||
:teams-to-detach 1
|
:teams-to-detach 1
|
||||||
|
:team-ids-to-delete [(:id extra-team)]
|
||||||
|
:transferable-teams []
|
||||||
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||||
:organization-member-count-before 2}
|
:organization-member-count-before 2}
|
||||||
(:result out)))))))
|
(:result out)))))))
|
||||||
|
|
||||||
|
(t/deftest get-leave-organization-summary-lists-transferable-teams-with-members
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-user owns both; only pair-team has another member
|
||||||
|
solo-team (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
pair-team (th/create-team* 2 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id pair-team)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :admin})
|
||||||
|
organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id organization-default-team)
|
||||||
|
organization-summary (make-organization-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:organization-teams [(:id solo-team) (:id pair-team)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-organization-summary-only-mock organization-summary)]
|
||||||
|
(let [out (th/command! {::th/type :get-leave-organization-summary
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:default-team-id your-penpot-id})
|
||||||
|
result (:result out)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (:teams-to-transfer result)))
|
||||||
|
(t/is (= [(:id solo-team)] (:team-ids-to-delete result)))
|
||||||
|
;; The members that can take over pair-team, without profile-user
|
||||||
|
(t/is (= [{:id (:id pair-team)
|
||||||
|
:name (:name pair-team)
|
||||||
|
:members [{:id (:id profile-owner)
|
||||||
|
:name (:fullname profile-owner)
|
||||||
|
:email (:email profile-owner)
|
||||||
|
:is-admin true}]}]
|
||||||
|
(:transferable-teams result)))))))
|
||||||
|
|
||||||
(t/deftest leave-organization-error-organization-owner-cannot-leave
|
(t/deftest leave-organization-error-organization-owner-cannot-leave
|
||||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
organization-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
organization-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
||||||
|
|||||||
@ -177,22 +177,13 @@
|
|||||||
(filter #(= (dm/get-in % [:organization :id]) organization-id))))
|
(filter #(= (dm/get-in % [:organization :id]) organization-id))))
|
||||||
|
|
||||||
(defn organization-leave-info
|
(defn organization-leave-info
|
||||||
"Splits the teams of an organization into what is needed to leave it:
|
"The default team id and the not-owned teams of an organization.
|
||||||
the organization's own default team id, the teams owned by the
|
Owned teams come from `::get-leave-organization-summary`."
|
||||||
current user (whose membership decides whether they get deleted or
|
|
||||||
offered for transfer), and the teams the user does not own (which
|
|
||||||
are simply left)."
|
|
||||||
[org-teams]
|
[org-teams]
|
||||||
(let [non-default-teams (remove :is-default org-teams)]
|
{:default-team-id (->> org-teams (filter :is-default) first :id)
|
||||||
{:default-team-id (->> org-teams (filter :is-default) first :id)
|
:not-owned-teams (->> org-teams
|
||||||
:owned-teams (filter #(dm/get-in % [:permissions :is-owner]) non-default-teams)
|
(remove :is-default)
|
||||||
:not-owned-teams (remove #(dm/get-in % [:permissions :is-owner]) non-default-teams)}))
|
(remove #(dm/get-in % [:permissions :is-owner])))})
|
||||||
|
|
||||||
(defn transferable-teams
|
|
||||||
"Owned teams with more than one member: the ones the user can offer
|
|
||||||
to transfer to another owner instead of leaving/deleting them."
|
|
||||||
[owned-teams]
|
|
||||||
(filter #(> (count (:members %)) 1) owned-teams))
|
|
||||||
|
|
||||||
(def ^:private team-leave-error-messages
|
(def ^:private team-leave-error-messages
|
||||||
{:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete"
|
{:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete"
|
||||||
@ -268,21 +259,15 @@
|
|||||||
(rx/catch on-error)))))))
|
(rx/catch on-error)))))))
|
||||||
|
|
||||||
(defn leave-organization-fn
|
(defn leave-organization-fn
|
||||||
"Builds the accept callback used by `show-leave-organization-modal`: it
|
"Builds the accept callback used by `show-leave-organization-modal`:
|
||||||
folds any teams the user chose to transfer into `:teams-to-leave`,
|
folds transferred teams into `:teams-to-leave` and emits
|
||||||
computes `:teams-to-delete` from the owned teams left with a single
|
`leave-organization`."
|
||||||
member, then emits `leave-organization`."
|
[{:keys [organization default-team-id not-owned-teams on-error]}]
|
||||||
[{:keys [organization default-team-id owned-teams not-owned-teams on-error]}]
|
(fn [{:keys [teams-to-transfer teams-to-delete member-added-at organization-member-count-before]}]
|
||||||
(fn [{:keys [teams-to-transfer member-added-at organization-member-count-before]}]
|
|
||||||
(let [teams-to-leave
|
(let [teams-to-leave
|
||||||
(cond->> not-owned-teams
|
(cond->> not-owned-teams
|
||||||
:always (map #(select-keys % [:id]))
|
:always (map #(select-keys % [:id]))
|
||||||
(seq teams-to-transfer) (concat teams-to-transfer))
|
(seq teams-to-transfer) (concat teams-to-transfer))]
|
||||||
|
|
||||||
teams-to-delete
|
|
||||||
(->> owned-teams
|
|
||||||
(filter #(= (count (:members %)) 1))
|
|
||||||
(map :id))]
|
|
||||||
(st/emit! (leave-organization {:id (:id organization)
|
(st/emit! (leave-organization {:id (:id organization)
|
||||||
:name (:name organization)
|
:name (:name organization)
|
||||||
:default-team-id default-team-id
|
:default-team-id default-team-id
|
||||||
@ -293,7 +278,7 @@
|
|||||||
:on-error on-error})))))
|
:on-error on-error})))))
|
||||||
|
|
||||||
(defn show-leave-organization-modal
|
(defn show-leave-organization-modal
|
||||||
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
|
[{:keys [organization profile default-team-id leave-fn on-error]}]
|
||||||
(ptk/reify ::show-leave-organization-modal
|
(ptk/reify ::show-leave-organization-modal
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ _]
|
(watch [_ _ _]
|
||||||
@ -309,6 +294,7 @@
|
|||||||
(fn [params]
|
(fn [params]
|
||||||
(leave-fn
|
(leave-fn
|
||||||
(assoc params
|
(assoc params
|
||||||
|
:teams-to-delete (:team-ids-to-delete summary)
|
||||||
:member-added-at (:member-added-at summary)
|
:member-added-at (:member-added-at summary)
|
||||||
:organization-member-count-before
|
:organization-member-count-before
|
||||||
(:organization-member-count-before summary))))]
|
(:organization-member-count-before summary))))]
|
||||||
@ -318,7 +304,7 @@
|
|||||||
(modal/show
|
(modal/show
|
||||||
{:type :leave-and-reassign-organization
|
{:type :leave-and-reassign-organization
|
||||||
:profile profile
|
:profile profile
|
||||||
:teams-to-transfer teams-to-transfer
|
:teams-to-transfer (:transferable-teams summary)
|
||||||
:num-teams-to-delete num-teams-to-delete
|
:num-teams-to-delete num-teams-to-delete
|
||||||
:accept leave-fn}))
|
:accept leave-fn}))
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,19 @@
|
|||||||
[team]
|
[team]
|
||||||
(if (:is-default team) (tr "dashboard.personal-projects") (:name team)))
|
(if (:is-default team) (tr "dashboard.personal-projects") (:name team)))
|
||||||
|
|
||||||
|
(defn show-subscription-badge?
|
||||||
|
"Whether `team`'s U/E subscription badge should render: never on a
|
||||||
|
default team (\"Personal projects\" has no billing of its own), and
|
||||||
|
never on a team that belongs to an organization, since a team's
|
||||||
|
`:subscription` reflects its owner's own plan, not any
|
||||||
|
organization-level billing. Pending a real editor/seat billing
|
||||||
|
model for organizations, only a standalone team outside every
|
||||||
|
organization shows its own plan."
|
||||||
|
[team]
|
||||||
|
(and (not (:is-default team))
|
||||||
|
(nil? (dm/get-in team [:organization :id]))
|
||||||
|
(contains? #{"unlimited" "enterprise"} (get-subscription-type (:subscription team)))))
|
||||||
|
|
||||||
(defn sort-organization-teams
|
(defn sort-organization-teams
|
||||||
"Orders the teams of a single organization for the dropdown's second
|
"Orders the teams of a single organization for the dropdown's second
|
||||||
column: alphabetical, with the organization's default team
|
column: alphabetical, with the organization's default team
|
||||||
@ -117,8 +130,9 @@
|
|||||||
(defn create-team-target-id
|
(defn create-team-target-id
|
||||||
"The organization default team id that \"Create new team\" targets,
|
"The organization default team id that \"Create new team\" targets,
|
||||||
or nil when the new team belongs under \"Other teams\". Creation
|
or nil when the new team belongs under \"Other teams\". Creation
|
||||||
always targets the open dashboard's organization, not the one
|
targets the organization currently previewed in the switcher's left
|
||||||
previewed in the switcher."
|
column, i.e. the one the user triggers the action from, not
|
||||||
|
necessarily the open dashboard's own organization."
|
||||||
[organizations organization-id]
|
[organizations organization-id]
|
||||||
(when-not (= organization-id personal-bucket-id)
|
(when-not (= organization-id personal-bucket-id)
|
||||||
(:default-team-id (get organizations organization-id))))
|
(:default-team-id (get organizations organization-id))))
|
||||||
@ -159,7 +173,7 @@
|
|||||||
|
|
||||||
(mf/defc organizations-column*
|
(mf/defc organizations-column*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [organizations selected-id ^boolean has-organizations? on-select on-create-organization
|
[{:keys [organizations selected-id current-id ^boolean has-organizations? on-select on-create-organization
|
||||||
admin-console-href ^boolean valid-license on-context-menu on-dismiss-context-menu]}]
|
admin-console-href ^boolean valid-license on-context-menu on-dismiss-context-menu]}]
|
||||||
[:li {:role "presentation" :class (stl/css :organizations-column)}
|
[:li {:role "presentation" :class (stl/css :organizations-column)}
|
||||||
[:div {:class (stl/css :column-label)}
|
[:div {:class (stl/css :column-label)}
|
||||||
@ -193,7 +207,7 @@
|
|||||||
[:span {:class (stl/css :organization-text)
|
[:span {:class (stl/css :organization-text)
|
||||||
:title (if personal? (tr "dashboard.other-teams") (:name organization))}
|
:title (if personal? (tr "dashboard.other-teams") (:name organization))}
|
||||||
(if personal? (tr "dashboard.other-teams") (:name organization))]
|
(if personal? (tr "dashboard.other-teams") (:name organization))]
|
||||||
(when (= bucket-id selected-id)
|
(when (= bucket-id current-id)
|
||||||
[:span {:class (stl/css :tick-icon)}
|
[:span {:class (stl/css :tick-icon)}
|
||||||
[:> icon* {:icon-id i/tick :size "s"}]])]
|
[:> icon* {:icon-id i/tick :size "s"}]])]
|
||||||
[:span {:class (stl/css :chevron-icon)}
|
[:span {:class (stl/css :chevron-icon)}
|
||||||
@ -236,7 +250,8 @@
|
|||||||
|
|
||||||
[:ul {:class (stl/css :column-list)}
|
[:ul {:class (stl/css :column-list)}
|
||||||
(for [team teams]
|
(for [team teams]
|
||||||
(let [subscription-type (get-subscription-type (:subscription team))]
|
(let [subscription-type (get-subscription-type (:subscription team))
|
||||||
|
show-badge? (show-subscription-badge? team)]
|
||||||
[:* {:key (str (:id team))}
|
[:* {:key (str (:id team))}
|
||||||
;; "Personal projects" is always last (see
|
;; "Personal projects" is always last (see
|
||||||
;; `sort-organization-teams`/`sort-all-teams`), set apart
|
;; `sort-organization-teams`/`sort-all-teams`), set apart
|
||||||
@ -264,7 +279,7 @@
|
|||||||
[:span {:class (stl/css :team-text)
|
[:span {:class (stl/css :team-text)
|
||||||
:title (team-display-name team)}
|
:title (team-display-name team)}
|
||||||
(team-display-name team)]
|
(team-display-name team)]
|
||||||
(when (#{"unlimited" "enterprise"} subscription-type)
|
(when show-badge?
|
||||||
[:> menu-team-icon* {:subscription-type subscription-type}])
|
[:> menu-team-icon* {:subscription-type subscription-type}])
|
||||||
(when (= (:id team) selected-team-id)
|
(when (= (:id team) selected-team-id)
|
||||||
[:span {:class (stl/css :tick-icon)}
|
[:span {:class (stl/css :tick-icon)}
|
||||||
@ -460,17 +475,18 @@
|
|||||||
(mf/with-memo [selected-organization profile]
|
(mf/with-memo [selected-organization profile]
|
||||||
(resolve-admin-console-href selected-organization profile))
|
(resolve-admin-console-href selected-organization profile))
|
||||||
|
|
||||||
;; "Create new team" targets the open dashboard's organization
|
;; "Create new team" targets the organization previewed on the
|
||||||
;; (`current-organization-id`), not the one previewed on the
|
;; left column (`selected-organization-id`), i.e. wherever the
|
||||||
;; left column; the story keeps this behaviour unchanged.
|
;; user triggers the action from, or "Other teams" when
|
||||||
|
;; that's the previewed group.
|
||||||
on-create-team
|
on-create-team
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps organizations current-organization-id)
|
(mf/deps organizations selected-organization-id)
|
||||||
(fn []
|
(fn []
|
||||||
(reset! show-menu* false)
|
(reset! show-menu* false)
|
||||||
(if (contains? cf/flags :admin-console)
|
(if (contains? cf/flags :admin-console)
|
||||||
(st/emit! (dtm/check-and-create-team
|
(st/emit! (dtm/check-and-create-team
|
||||||
(create-team-target-id organizations current-organization-id)))
|
(create-team-target-id organizations selected-organization-id)))
|
||||||
(st/emit! (modal/show :team-form {})))))
|
(st/emit! (modal/show :team-form {})))))
|
||||||
|
|
||||||
on-close-context-menu
|
on-close-context-menu
|
||||||
@ -531,7 +547,7 @@
|
|||||||
[:span {:class (stl/css :current-team-name)
|
[:span {:class (stl/css :current-team-name)
|
||||||
:title (team-display-name team)}
|
:title (team-display-name team)}
|
||||||
(team-display-name team)]
|
(team-display-name team)]
|
||||||
(when (#{"unlimited" "enterprise"} current-team-subscription-type)
|
(when (show-subscription-badge? team)
|
||||||
[:> menu-team-icon* {:subscription-type current-team-subscription-type}])]
|
[:> menu-team-icon* {:subscription-type current-team-subscription-type}])]
|
||||||
(when-let [organization-name (closed-control-line-2 has-organizations? current-organization)]
|
(when-let [organization-name (closed-control-line-2 has-organizations? current-organization)]
|
||||||
[:span {:class (stl/css :current-organization-name)
|
[:span {:class (stl/css :current-organization-name)
|
||||||
@ -565,6 +581,7 @@
|
|||||||
:class (stl/css :organization-team-dropdown)}
|
:class (stl/css :organization-team-dropdown)}
|
||||||
[:> organizations-column* {:organizations (sort-organizations (vals organizations))
|
[:> organizations-column* {:organizations (sort-organizations (vals organizations))
|
||||||
:selected-id selected-organization-id
|
:selected-id selected-organization-id
|
||||||
|
:current-id current-organization-id
|
||||||
:has-organizations? has-organizations?
|
:has-organizations? has-organizations?
|
||||||
:on-select on-organization-select
|
:on-select on-organization-select
|
||||||
:on-create-organization on-create-organization
|
:on-create-organization on-create-organization
|
||||||
|
|||||||
@ -24,33 +24,10 @@
|
|||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(defn- fetch-missing-team-members!
|
|
||||||
"Fetches member lists for any `owned-teams` that do not have them
|
|
||||||
loaded yet; shared by the team-options menu and the organization
|
|
||||||
context menu, both of which need `:members` to decide how a team
|
|
||||||
leave/delete should behave."
|
|
||||||
[owned-teams]
|
|
||||||
(doseq [owned-team owned-teams
|
|
||||||
:when (not (contains? owned-team :members))]
|
|
||||||
(st/emit! (dtm/fetch-members (:id owned-team)))))
|
|
||||||
|
|
||||||
(defn- use-organization-leave
|
(defn- use-organization-leave
|
||||||
"Data needed to leave `organization`, out of its `teams`: the
|
"The default team id and `:leave-fn` needed to leave `organization`.
|
||||||
organization's own default team id, the teams owned by the current
|
Fetches nothing: fetching team members would hit the SSO gate. Pass
|
||||||
user (`:owned-teams`) split from the ones they don't own
|
`enabled?` false when leaving isn't offered."
|
||||||
(`:not-owned-teams`), which owned teams can be offered for transfer
|
|
||||||
(`:teams-to-transfer`), and the ready-to-call `:leave-fn` that
|
|
||||||
`show-leave-organization-modal` expects as its accept callback.
|
|
||||||
Shared by `options-dropdown*` and `organization-context-menu*`, the
|
|
||||||
two places that offer a \"leave organization\" action. Also fetches
|
|
||||||
member lists for any owned team that doesn't have them loaded yet,
|
|
||||||
since leaving needs `:members` to decide delete vs. transfer.
|
|
||||||
|
|
||||||
`enabled?` must stay false whenever leaving isn't actually offered
|
|
||||||
(e.g. `options-dropdown*` when the profile can't leave the
|
|
||||||
organization): called unconditionally like any hook, but it skips
|
|
||||||
the teams/members lookup and the member-fetching effect, so no
|
|
||||||
RPC calls are made for an action that's not on offer."
|
|
||||||
[organization teams ^boolean enabled?]
|
[organization teams ^boolean enabled?]
|
||||||
(let [org-teams
|
(let [org-teams
|
||||||
(mf/with-memo [teams organization enabled?]
|
(mf/with-memo [teams organization enabled?]
|
||||||
@ -58,36 +35,20 @@
|
|||||||
(dnt/organization-teams teams (:id organization))))
|
(dnt/organization-teams teams (:id organization))))
|
||||||
|
|
||||||
{default-team-id :default-team-id
|
{default-team-id :default-team-id
|
||||||
owned-teams :owned-teams
|
|
||||||
not-owned-teams :not-owned-teams}
|
not-owned-teams :not-owned-teams}
|
||||||
(mf/with-memo [org-teams]
|
(mf/with-memo [org-teams]
|
||||||
(when org-teams
|
(when org-teams
|
||||||
(dnt/organization-leave-info org-teams)))
|
(dnt/organization-leave-info org-teams)))
|
||||||
|
|
||||||
teams-to-transfer
|
|
||||||
(mf/with-memo [owned-teams]
|
|
||||||
(when owned-teams
|
|
||||||
(dnt/transferable-teams owned-teams)))
|
|
||||||
|
|
||||||
leave-fn
|
leave-fn
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps organization default-team-id owned-teams not-owned-teams)
|
(mf/deps organization default-team-id not-owned-teams)
|
||||||
(dnt/leave-organization-fn {:organization organization
|
(dnt/leave-organization-fn {:organization organization
|
||||||
:default-team-id default-team-id
|
:default-team-id default-team-id
|
||||||
:owned-teams owned-teams
|
|
||||||
:not-owned-teams not-owned-teams
|
:not-owned-teams not-owned-teams
|
||||||
:on-error dnt/org-leave-on-error}))]
|
:on-error dnt/org-leave-on-error}))]
|
||||||
|
|
||||||
(mf/use-effect
|
|
||||||
(mf/deps owned-teams)
|
|
||||||
(fn []
|
|
||||||
(when (seq owned-teams)
|
|
||||||
(fetch-missing-team-members! owned-teams))))
|
|
||||||
|
|
||||||
{:default-team-id default-team-id
|
{:default-team-id default-team-id
|
||||||
:owned-teams owned-teams
|
|
||||||
:not-owned-teams not-owned-teams
|
|
||||||
:teams-to-transfer teams-to-transfer
|
|
||||||
:leave-fn leave-fn}))
|
:leave-fn leave-fn}))
|
||||||
|
|
||||||
(mf/defc options-dropdown*
|
(mf/defc options-dropdown*
|
||||||
@ -108,14 +69,9 @@
|
|||||||
(not (:is-default team))
|
(not (:is-default team))
|
||||||
|
|
||||||
{org-default-team-id :default-team-id
|
{org-default-team-id :default-team-id
|
||||||
org-owned-teams :owned-teams
|
|
||||||
teams-to-transfer :teams-to-transfer
|
|
||||||
org-leave-fn :leave-fn}
|
org-leave-fn :leave-fn}
|
||||||
(use-organization-leave current-organization teams can-leave-organization)
|
(use-organization-leave current-organization teams can-leave-organization)
|
||||||
|
|
||||||
owned-teams-members-loaded?
|
|
||||||
(every? #(contains? % :members) org-owned-teams)
|
|
||||||
|
|
||||||
on-success
|
on-success
|
||||||
(fn []
|
(fn []
|
||||||
(rx/of (dcm/go-to-dashboard-recent :team-id default-team-id)
|
(rx/of (dcm/go-to-dashboard-recent :team-id default-team-id)
|
||||||
@ -203,18 +159,15 @@
|
|||||||
|
|
||||||
on-leave-organization-clicked
|
on-leave-organization-clicked
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps profile current-organization org-default-team-id teams-to-transfer
|
(mf/deps profile current-organization org-default-team-id org-leave-fn)
|
||||||
org-leave-fn owned-teams-members-loaded?)
|
|
||||||
(fn []
|
(fn []
|
||||||
(when owned-teams-members-loaded?
|
(on-close)
|
||||||
(on-close)
|
(st/emit! (dnt/show-leave-organization-modal
|
||||||
(st/emit! (dnt/show-leave-organization-modal
|
{:organization current-organization
|
||||||
{:organization current-organization
|
:profile profile
|
||||||
:profile profile
|
:default-team-id org-default-team-id
|
||||||
:default-team-id org-default-team-id
|
:leave-fn org-leave-fn
|
||||||
:leave-fn org-leave-fn
|
:on-error dnt/org-leave-on-error}))))]
|
||||||
:teams-to-transfer teams-to-transfer
|
|
||||||
:on-error dnt/org-leave-on-error})))))]
|
|
||||||
|
|
||||||
[:> dropdown-menu* {:show show
|
[:> dropdown-menu* {:show show
|
||||||
:on-close on-close
|
:on-close on-close
|
||||||
@ -284,37 +237,23 @@
|
|||||||
(mf/defc organization-context-menu*
|
(mf/defc organization-context-menu*
|
||||||
"Right-click menu on an organization in `organizations-column*`,
|
"Right-click menu on an organization in `organizations-column*`,
|
||||||
currently offering just \"leave organization\". Split out of
|
currently offering just \"leave organization\". Split out of
|
||||||
`organization-team-switch*` so its leave-organization data (owned
|
`organization-team-switch*` so its leave-organization data stays
|
||||||
teams, transfer candidates, member loading) stays local instead of
|
local instead of being computed on every render of the switcher."
|
||||||
being computed unconditionally on every render of the switcher."
|
|
||||||
[{:keys [organization teams profile x y on-close on-leave-requested]}]
|
[{:keys [organization teams profile x y on-close on-leave-requested]}]
|
||||||
(let [{default-team-id :default-team-id
|
(let [{default-team-id :default-team-id
|
||||||
owned-teams :owned-teams
|
|
||||||
teams-to-transfer :teams-to-transfer
|
|
||||||
leave-fn :leave-fn}
|
leave-fn :leave-fn}
|
||||||
(use-organization-leave organization teams true)
|
(use-organization-leave organization teams true)
|
||||||
|
|
||||||
owned-teams-members-loaded?
|
|
||||||
(every? #(contains? % :members) owned-teams)
|
|
||||||
|
|
||||||
on-leave-clicked
|
on-leave-clicked
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps leave-fn
|
(mf/deps leave-fn profile organization default-team-id on-leave-requested)
|
||||||
profile
|
|
||||||
organization
|
|
||||||
default-team-id
|
|
||||||
teams-to-transfer
|
|
||||||
owned-teams-members-loaded?
|
|
||||||
on-leave-requested)
|
|
||||||
(fn []
|
(fn []
|
||||||
(when owned-teams-members-loaded?
|
(on-leave-requested)
|
||||||
(on-leave-requested)
|
(st/emit! (dnt/show-leave-organization-modal {:organization organization
|
||||||
(st/emit! (dnt/show-leave-organization-modal {:organization organization
|
:profile profile
|
||||||
:profile profile
|
:default-team-id default-team-id
|
||||||
:default-team-id default-team-id
|
:leave-fn leave-fn
|
||||||
:leave-fn leave-fn
|
:on-error dnt/org-leave-on-error}))))]
|
||||||
:teams-to-transfer teams-to-transfer
|
|
||||||
:on-error dnt/org-leave-on-error})))))]
|
|
||||||
|
|
||||||
[:> dropdown-menu* {:show true
|
[:> dropdown-menu* {:show true
|
||||||
:on-close on-close
|
:on-close on-close
|
||||||
|
|||||||
@ -14,10 +14,12 @@
|
|||||||
[app.main.data.nitrate-audit :as nitrate-audit]
|
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||||
[app.main.data.notifications :as ntf]
|
[app.main.data.notifications :as ntf]
|
||||||
[app.main.data.team :as dt]
|
[app.main.data.team :as dt]
|
||||||
|
[app.main.repo :as rp]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.ui.auth.verify-token :as verify-token]
|
[app.main.ui.auth.verify-token :as verify-token]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cljs.test :as t :include-macros true]
|
[cljs.test :as t :include-macros true]
|
||||||
|
[frontend-tests.helpers.mock :as mock]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
(t/deftest account-age-days-test
|
(t/deftest account-age-days-test
|
||||||
@ -270,31 +272,20 @@
|
|||||||
(t/is (= ["t1" "t4"] (map :id (dnt/organization-teams teams "org-a"))))
|
(t/is (= ["t1" "t4"] (map :id (dnt/organization-teams teams "org-a"))))
|
||||||
(t/is (= [] (dnt/organization-teams teams "org-c")))))
|
(t/is (= [] (dnt/organization-teams teams "org-c")))))
|
||||||
|
|
||||||
(t/deftest organization-leave-info-splits-owned-and-not-owned-teams
|
(t/deftest organization-leave-info-keeps-default-and-not-owned-teams
|
||||||
(let [org-teams [{:id "default" :is-default true}
|
(let [org-teams [{:id "default" :is-default true}
|
||||||
{:id "owned-1" :permissions {:is-owner true}}
|
{:id "owned-1" :permissions {:is-owner true}}
|
||||||
{:id "owned-2" :permissions {:is-owner true}}
|
|
||||||
{:id "member-1" :permissions {:is-owner false}}]
|
{:id "member-1" :permissions {:is-owner false}}]
|
||||||
info (dnt/organization-leave-info org-teams)]
|
info (dnt/organization-leave-info org-teams)]
|
||||||
(t/is (= "default" (:default-team-id info)))
|
(t/is (= "default" (:default-team-id info)))
|
||||||
(t/is (= ["owned-1" "owned-2"] (map :id (:owned-teams info))))
|
|
||||||
(t/is (= ["member-1"] (map :id (:not-owned-teams info))))))
|
(t/is (= ["member-1"] (map :id (:not-owned-teams info))))))
|
||||||
|
|
||||||
(t/deftest transferable-teams-boundary-at-one-member
|
|
||||||
(let [owned-teams [{:id "solo" :members [{:id "m1"}]}
|
|
||||||
{:id "pair" :members [{:id "m1"} {:id "m2"}]}
|
|
||||||
{:id "empty" :members []}]]
|
|
||||||
(t/is (= ["pair"] (map :id (dnt/transferable-teams owned-teams))))))
|
|
||||||
|
|
||||||
(t/deftest leave-organization-fn-builds-delete-and-leave-lists
|
(t/deftest leave-organization-fn-builds-delete-and-leave-lists
|
||||||
(let [captured (atom nil)
|
(let [captured (atom nil)
|
||||||
emitted (atom [])
|
emitted (atom [])
|
||||||
owned-teams [{:id "solo" :members [{:id "m1"}]}
|
|
||||||
{:id "pair" :members [{:id "m1"} {:id "m2"}]}]
|
|
||||||
not-owned-teams [{:id "member-1" :name "extra"}]
|
not-owned-teams [{:id "member-1" :name "extra"}]
|
||||||
leave-fn (dnt/leave-organization-fn {:organization {:id "org-1" :name "Acme"}
|
leave-fn (dnt/leave-organization-fn {:organization {:id "org-1" :name "Acme"}
|
||||||
:default-team-id "default"
|
:default-team-id "default"
|
||||||
:owned-teams owned-teams
|
|
||||||
:not-owned-teams not-owned-teams
|
:not-owned-teams not-owned-teams
|
||||||
:on-error :on-error-fn})]
|
:on-error :on-error-fn})]
|
||||||
(with-redefs [dnt/leave-organization (fn [params] (reset! captured params) ::leave-event)
|
(with-redefs [dnt/leave-organization (fn [params] (reset! captured params) ::leave-event)
|
||||||
@ -304,6 +295,7 @@
|
|||||||
|
|
||||||
(t/testing "with no teams offered for transfer"
|
(t/testing "with no teams offered for transfer"
|
||||||
(leave-fn {:teams-to-transfer nil
|
(leave-fn {:teams-to-transfer nil
|
||||||
|
:teams-to-delete ["solo"]
|
||||||
:member-added-at "2026-07-17T00:00:00Z"
|
:member-added-at "2026-07-17T00:00:00Z"
|
||||||
:organization-member-count-before 3})
|
:organization-member-count-before 3})
|
||||||
|
|
||||||
@ -327,6 +319,45 @@
|
|||||||
{:id "member-1"}]
|
{:id "member-1"}]
|
||||||
(:teams-to-leave @captured)))))))
|
(:teams-to-leave @captured)))))))
|
||||||
|
|
||||||
|
(t/deftest show-leave-organization-modal-takes-owned-teams-from-the-summary
|
||||||
|
(let [requests (atom [])
|
||||||
|
accepted (atom nil)
|
||||||
|
transferable [{:id "pair" :name "Pair" :members [{:id "m2" :name "Other"}]}]
|
||||||
|
summary {:teams-to-delete 1
|
||||||
|
:teams-to-transfer 1
|
||||||
|
:teams-to-exit 0
|
||||||
|
:teams-to-detach 0
|
||||||
|
:team-ids-to-delete ["solo"]
|
||||||
|
:transferable-teams transferable
|
||||||
|
:member-added-at "2026-07-17T00:00:00Z"
|
||||||
|
:organization-member-count-before 3}
|
||||||
|
event (dnt/show-leave-organization-modal
|
||||||
|
{:organization {:id "org-1" :name "Acme"}
|
||||||
|
:profile {:id "me"}
|
||||||
|
:default-team-id "default"
|
||||||
|
:leave-fn #(reset! accepted %)
|
||||||
|
:on-error identity})]
|
||||||
|
(with-redefs [rp/cmd! (mock/stub
|
||||||
|
(fn [cmd params]
|
||||||
|
(swap! requests conj cmd)
|
||||||
|
(t/is (= {:id "org-1" :default-team-id "default"} params))
|
||||||
|
(rx/of summary)))
|
||||||
|
modal/show (mock/stub (fn [params] params))]
|
||||||
|
(let [modal-params (atom nil)]
|
||||||
|
(rx/sub! (ptk/watch event nil nil) #(reset! modal-params %))
|
||||||
|
|
||||||
|
;; The summary is the only request: no SSO gated member fetch
|
||||||
|
(t/is (= [::dnt/get-leave-organization-summary] @requests))
|
||||||
|
(t/is (= :leave-and-reassign-organization (:type @modal-params)))
|
||||||
|
(t/is (= transferable (:teams-to-transfer @modal-params)))
|
||||||
|
|
||||||
|
((:accept @modal-params) {:teams-to-transfer [{:id "pair" :reassign-to "m2"}]})
|
||||||
|
(t/is (= {:teams-to-transfer [{:id "pair" :reassign-to "m2"}]
|
||||||
|
:teams-to-delete ["solo"]
|
||||||
|
:member-added-at "2026-07-17T00:00:00Z"
|
||||||
|
:organization-member-count-before 3}
|
||||||
|
@accepted))))))
|
||||||
|
|
||||||
(t/deftest team-leave-on-error-matrix
|
(t/deftest team-leave-on-error-matrix
|
||||||
(t/testing "known error code shows a translated notification"
|
(t/testing "known error code shows a translated notification"
|
||||||
(let [emitted (atom [])]
|
(let [emitted (atom [])]
|
||||||
|
|||||||
@ -57,16 +57,16 @@
|
|||||||
(t/testing "profile with no organizations at all: line absent"
|
(t/testing "profile with no organizations at all: line absent"
|
||||||
(t/is (nil? (dts/closed-control-line-2 false nil)))))
|
(t/is (nil? (dts/closed-control-line-2 false nil)))))
|
||||||
|
|
||||||
(t/deftest create-team-targets-the-current-organization
|
(t/deftest create-team-targets-the-previewed-organization
|
||||||
(let [organizations {"org-a" {:id "org-a" :name "Org A" :default-team-id "team-a-default"}
|
(let [organizations {"org-a" {:id "org-a" :name "Org A" :default-team-id "team-a-default"}
|
||||||
"org-b" {:id "org-b" :name "Org B" :default-team-id "team-b-default"}
|
"org-b" {:id "org-b" :name "Org B" :default-team-id "team-b-default"}
|
||||||
nil nil}
|
nil nil}
|
||||||
other-teams-id (dts/organization-bucket-id nil)]
|
other-teams-id (dts/organization-bucket-id nil)]
|
||||||
(t/testing "target is the open dashboard organization's default team"
|
(t/testing "target is the previewed organization's default team"
|
||||||
(t/is (= "team-a-default" (dts/create-team-target-id organizations "org-a")))
|
(t/is (= "team-a-default" (dts/create-team-target-id organizations "org-a")))
|
||||||
(t/is (= "team-b-default" (dts/create-team-target-id organizations "org-b"))))
|
(t/is (= "team-b-default" (dts/create-team-target-id organizations "org-b"))))
|
||||||
|
|
||||||
(t/testing "current destination is Other teams: no organization target"
|
(t/testing "previewed group is Other teams: no organization target"
|
||||||
(t/is (nil? (dts/create-team-target-id organizations other-teams-id))))))
|
(t/is (nil? (dts/create-team-target-id organizations other-teams-id))))))
|
||||||
|
|
||||||
(t/deftest teams-for-organization-filters-and-sorts-per-organization
|
(t/deftest teams-for-organization-filters-and-sorts-per-organization
|
||||||
@ -117,6 +117,22 @@
|
|||||||
(t/testing "flag absent: fallback action hidden"
|
(t/testing "flag absent: fallback action hidden"
|
||||||
(t/is (false? (dts/show-create-organization-in-teams-column? #{})))))
|
(t/is (false? (dts/show-create-organization-in-teams-column? #{})))))
|
||||||
|
|
||||||
|
(t/deftest show-subscription-badge-matrix
|
||||||
|
(t/testing "default team: never, regardless of its own subscription"
|
||||||
|
(t/is (false? (dts/show-subscription-badge? {:is-default true
|
||||||
|
:subscription {:type "unlimited"}}))))
|
||||||
|
|
||||||
|
(t/testing "team in an organization: never, regardless of its own subscription"
|
||||||
|
(t/is (false? (dts/show-subscription-badge? {:organization {:id "org-a"}
|
||||||
|
:subscription {:type "unlimited"}}))))
|
||||||
|
|
||||||
|
(t/testing "standalone team outside every organization: shown for unlimited/enterprise"
|
||||||
|
(t/is (true? (dts/show-subscription-badge? {:subscription {:type "unlimited"}})))
|
||||||
|
(t/is (true? (dts/show-subscription-badge? {:subscription {:type "enterprise"}}))))
|
||||||
|
|
||||||
|
(t/testing "standalone team with a professional plan: not shown"
|
||||||
|
(t/is (false? (dts/show-subscription-badge? {:subscription {:type "professional"}})))))
|
||||||
|
|
||||||
(t/deftest admin-console-href-resolution-matrix
|
(t/deftest admin-console-href-resolution-matrix
|
||||||
(t/testing "owner of the previewed organization: organization-specific href"
|
(t/testing "owner of the previewed organization: organization-specific href"
|
||||||
(let [organization {:id "org-a" :slug "org-a-slug" :owner-id "profile-1"}
|
(let [organization {:id "org-a" :slug "org-a-slug" :owner-id "profile-1"}
|
||||||
|
|||||||
@ -10931,4 +10931,4 @@ msgid "labels.team-management"
|
|||||||
msgstr "Team management"
|
msgstr "Team management"
|
||||||
|
|
||||||
msgid "dashboard.no-organizations-yet"
|
msgid "dashboard.no-organizations-yet"
|
||||||
msgstr "You don't belong to any organization yet"
|
msgstr "No organizations yet."
|
||||||
|
|||||||
@ -10563,4 +10563,4 @@ msgid "labels.team-management"
|
|||||||
msgstr "Gestión de equipos"
|
msgstr "Gestión de equipos"
|
||||||
|
|
||||||
msgid "dashboard.no-organizations-yet"
|
msgid "dashboard.no-organizations-yet"
|
||||||
msgstr "Todavía no perteneces a ninguna organización"
|
msgstr "Todavía no hay organizaciones."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user