🐛 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:
María Valderrama 2026-09-25 09:54:58 +02:00 committed by GitHub
parent dfd28b1e57
commit f35e12f716
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 201 additions and 140 deletions

View File

@ -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))))

View File

@ -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)})

View File

@ -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}))

View File

@ -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

View File

@ -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

View File

@ -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 [])]

View File

@ -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"}

View File

@ -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."

View File

@ -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."