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
|
||||
"SELECT t.id,
|
||||
t.name,
|
||||
t.is_default,
|
||||
tpr.is_owner,
|
||||
(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-exit ::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]]
|
||||
[:organization-member-count-before ::sm/int]])
|
||||
|
||||
@ -361,6 +375,7 @@
|
||||
|
||||
(sv/defmethod ::leave-organization
|
||||
{::rpc/auth true
|
||||
::nitrate/sso false
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:leave-organization
|
||||
::db/transaction true}
|
||||
@ -371,8 +386,21 @@
|
||||
: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
|
||||
{::rpc/auth true
|
||||
::nitrate/sso false
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:get-leave-organization-summary
|
||||
::sm/result schema:get-leave-organization-summary-result
|
||||
@ -394,6 +422,8 @@
|
||||
:code :not-valid-teams))
|
||||
(assoc
|
||||
(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)
|
||||
:organization-member-count-before (count organization-members))))
|
||||
|
||||
|
||||
@ -584,6 +584,8 @@
|
||||
:teams-to-transfer 0
|
||||
:teams-to-exit 0
|
||||
:teams-to-detach 0
|
||||
:team-ids-to-delete []
|
||||
:transferable-teams []
|
||||
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||
:organization-member-count-before 2}
|
||||
(:result out)))))))
|
||||
@ -618,10 +620,50 @@
|
||||
:teams-to-transfer 0
|
||||
:teams-to-exit 0
|
||||
:teams-to-detach 1
|
||||
:team-ids-to-delete [(:id extra-team)]
|
||||
:transferable-teams []
|
||||
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||
:organization-member-count-before 2}
|
||||
(: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
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
organization-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
||||
|
||||
@ -177,22 +177,13 @@
|
||||
(filter #(= (dm/get-in % [:organization :id]) organization-id))))
|
||||
|
||||
(defn organization-leave-info
|
||||
"Splits the teams of an organization into what is needed to leave it:
|
||||
the organization's own default team id, the teams owned by the
|
||||
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)."
|
||||
"The default team id and the not-owned teams of an organization.
|
||||
Owned teams come from `::get-leave-organization-summary`."
|
||||
[org-teams]
|
||||
(let [non-default-teams (remove :is-default org-teams)]
|
||||
{:default-team-id (->> org-teams (filter :is-default) first :id)
|
||||
:owned-teams (filter #(dm/get-in % [:permissions :is-owner]) non-default-teams)
|
||||
:not-owned-teams (remove #(dm/get-in % [:permissions :is-owner]) non-default-teams)}))
|
||||
|
||||
(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))
|
||||
{:default-team-id (->> org-teams (filter :is-default) first :id)
|
||||
:not-owned-teams (->> org-teams
|
||||
(remove :is-default)
|
||||
(remove #(dm/get-in % [:permissions :is-owner])))})
|
||||
|
||||
(def ^:private team-leave-error-messages
|
||||
{:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete"
|
||||
@ -268,21 +259,15 @@
|
||||
(rx/catch on-error)))))))
|
||||
|
||||
(defn leave-organization-fn
|
||||
"Builds the accept callback used by `show-leave-organization-modal`: it
|
||||
folds any teams the user chose to transfer into `:teams-to-leave`,
|
||||
computes `:teams-to-delete` from the owned teams left with a single
|
||||
member, then emits `leave-organization`."
|
||||
[{:keys [organization default-team-id owned-teams not-owned-teams on-error]}]
|
||||
(fn [{:keys [teams-to-transfer member-added-at organization-member-count-before]}]
|
||||
"Builds the accept callback used by `show-leave-organization-modal`:
|
||||
folds transferred teams into `:teams-to-leave` and emits
|
||||
`leave-organization`."
|
||||
[{:keys [organization default-team-id not-owned-teams on-error]}]
|
||||
(fn [{:keys [teams-to-transfer teams-to-delete member-added-at organization-member-count-before]}]
|
||||
(let [teams-to-leave
|
||||
(cond->> not-owned-teams
|
||||
:always (map #(select-keys % [:id]))
|
||||
(seq teams-to-transfer) (concat teams-to-transfer))
|
||||
|
||||
teams-to-delete
|
||||
(->> owned-teams
|
||||
(filter #(= (count (:members %)) 1))
|
||||
(map :id))]
|
||||
(seq teams-to-transfer) (concat teams-to-transfer))]
|
||||
(st/emit! (leave-organization {:id (:id organization)
|
||||
:name (:name organization)
|
||||
:default-team-id default-team-id
|
||||
@ -293,7 +278,7 @@
|
||||
:on-error on-error})))))
|
||||
|
||||
(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/WatchEvent
|
||||
(watch [_ _ _]
|
||||
@ -309,6 +294,7 @@
|
||||
(fn [params]
|
||||
(leave-fn
|
||||
(assoc params
|
||||
:teams-to-delete (:team-ids-to-delete summary)
|
||||
:member-added-at (:member-added-at summary)
|
||||
:organization-member-count-before
|
||||
(:organization-member-count-before summary))))]
|
||||
@ -318,7 +304,7 @@
|
||||
(modal/show
|
||||
{:type :leave-and-reassign-organization
|
||||
:profile profile
|
||||
:teams-to-transfer teams-to-transfer
|
||||
:teams-to-transfer (:transferable-teams summary)
|
||||
:num-teams-to-delete num-teams-to-delete
|
||||
:accept leave-fn}))
|
||||
|
||||
|
||||
@ -56,6 +56,19 @@
|
||||
[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
|
||||
"Orders the teams of a single organization for the dropdown's second
|
||||
column: alphabetical, with the organization's default team
|
||||
@ -117,8 +130,9 @@
|
||||
(defn create-team-target-id
|
||||
"The organization default team id that \"Create new team\" targets,
|
||||
or nil when the new team belongs under \"Other teams\". Creation
|
||||
always targets the open dashboard's organization, not the one
|
||||
previewed in the switcher."
|
||||
targets the organization currently previewed in the switcher's left
|
||||
column, i.e. the one the user triggers the action from, not
|
||||
necessarily the open dashboard's own organization."
|
||||
[organizations organization-id]
|
||||
(when-not (= organization-id personal-bucket-id)
|
||||
(:default-team-id (get organizations organization-id))))
|
||||
@ -159,7 +173,7 @@
|
||||
|
||||
(mf/defc organizations-column*
|
||||
{::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]}]
|
||||
[:li {:role "presentation" :class (stl/css :organizations-column)}
|
||||
[:div {:class (stl/css :column-label)}
|
||||
@ -193,7 +207,7 @@
|
||||
[:span {:class (stl/css :organization-text)
|
||||
:title (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)}
|
||||
[:> icon* {:icon-id i/tick :size "s"}]])]
|
||||
[:span {:class (stl/css :chevron-icon)}
|
||||
@ -236,7 +250,8 @@
|
||||
|
||||
[:ul {:class (stl/css :column-list)}
|
||||
(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))}
|
||||
;; "Personal projects" is always last (see
|
||||
;; `sort-organization-teams`/`sort-all-teams`), set apart
|
||||
@ -264,7 +279,7 @@
|
||||
[:span {:class (stl/css :team-text)
|
||||
:title (team-display-name team)}
|
||||
(team-display-name team)]
|
||||
(when (#{"unlimited" "enterprise"} subscription-type)
|
||||
(when show-badge?
|
||||
[:> menu-team-icon* {:subscription-type subscription-type}])
|
||||
(when (= (:id team) selected-team-id)
|
||||
[:span {:class (stl/css :tick-icon)}
|
||||
@ -460,17 +475,18 @@
|
||||
(mf/with-memo [selected-organization profile]
|
||||
(resolve-admin-console-href selected-organization profile))
|
||||
|
||||
;; "Create new team" targets the open dashboard's organization
|
||||
;; (`current-organization-id`), not the one previewed on the
|
||||
;; left column; the story keeps this behaviour unchanged.
|
||||
;; "Create new team" targets the organization previewed on the
|
||||
;; left column (`selected-organization-id`), i.e. wherever the
|
||||
;; user triggers the action from, or "Other teams" when
|
||||
;; that's the previewed group.
|
||||
on-create-team
|
||||
(mf/use-fn
|
||||
(mf/deps organizations current-organization-id)
|
||||
(mf/deps organizations selected-organization-id)
|
||||
(fn []
|
||||
(reset! show-menu* false)
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(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 {})))))
|
||||
|
||||
on-close-context-menu
|
||||
@ -531,7 +547,7 @@
|
||||
[:span {:class (stl/css :current-team-name)
|
||||
:title (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}])]
|
||||
(when-let [organization-name (closed-control-line-2 has-organizations? current-organization)]
|
||||
[:span {:class (stl/css :current-organization-name)
|
||||
@ -565,6 +581,7 @@
|
||||
:class (stl/css :organization-team-dropdown)}
|
||||
[:> organizations-column* {:organizations (sort-organizations (vals organizations))
|
||||
:selected-id selected-organization-id
|
||||
:current-id current-organization-id
|
||||
:has-organizations? has-organizations?
|
||||
:on-select on-organization-select
|
||||
:on-create-organization on-create-organization
|
||||
|
||||
@ -24,33 +24,10 @@
|
||||
[beicon.v2.core :as rx]
|
||||
[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
|
||||
"Data needed to leave `organization`, out of its `teams`: the
|
||||
organization's own default team id, the teams owned by the current
|
||||
user (`:owned-teams`) split from the ones they don't own
|
||||
(`: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."
|
||||
"The default team id and `:leave-fn` needed to leave `organization`.
|
||||
Fetches nothing: fetching team members would hit the SSO gate. Pass
|
||||
`enabled?` false when leaving isn't offered."
|
||||
[organization teams ^boolean enabled?]
|
||||
(let [org-teams
|
||||
(mf/with-memo [teams organization enabled?]
|
||||
@ -58,36 +35,20 @@
|
||||
(dnt/organization-teams teams (:id organization))))
|
||||
|
||||
{default-team-id :default-team-id
|
||||
owned-teams :owned-teams
|
||||
not-owned-teams :not-owned-teams}
|
||||
(mf/with-memo [org-teams]
|
||||
(when 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
|
||||
(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
|
||||
:default-team-id default-team-id
|
||||
:owned-teams owned-teams
|
||||
:not-owned-teams not-owned-teams
|
||||
: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
|
||||
:owned-teams owned-teams
|
||||
:not-owned-teams not-owned-teams
|
||||
:teams-to-transfer teams-to-transfer
|
||||
:leave-fn leave-fn}))
|
||||
|
||||
(mf/defc options-dropdown*
|
||||
@ -108,14 +69,9 @@
|
||||
(not (:is-default team))
|
||||
|
||||
{org-default-team-id :default-team-id
|
||||
org-owned-teams :owned-teams
|
||||
teams-to-transfer :teams-to-transfer
|
||||
org-leave-fn :leave-fn}
|
||||
(use-organization-leave current-organization teams can-leave-organization)
|
||||
|
||||
owned-teams-members-loaded?
|
||||
(every? #(contains? % :members) org-owned-teams)
|
||||
|
||||
on-success
|
||||
(fn []
|
||||
(rx/of (dcm/go-to-dashboard-recent :team-id default-team-id)
|
||||
@ -203,18 +159,15 @@
|
||||
|
||||
on-leave-organization-clicked
|
||||
(mf/use-fn
|
||||
(mf/deps profile current-organization org-default-team-id teams-to-transfer
|
||||
org-leave-fn owned-teams-members-loaded?)
|
||||
(mf/deps profile current-organization org-default-team-id org-leave-fn)
|
||||
(fn []
|
||||
(when owned-teams-members-loaded?
|
||||
(on-close)
|
||||
(st/emit! (dnt/show-leave-organization-modal
|
||||
{:organization current-organization
|
||||
:profile profile
|
||||
:default-team-id org-default-team-id
|
||||
:leave-fn org-leave-fn
|
||||
:teams-to-transfer teams-to-transfer
|
||||
:on-error dnt/org-leave-on-error})))))]
|
||||
(on-close)
|
||||
(st/emit! (dnt/show-leave-organization-modal
|
||||
{:organization current-organization
|
||||
:profile profile
|
||||
:default-team-id org-default-team-id
|
||||
:leave-fn org-leave-fn
|
||||
:on-error dnt/org-leave-on-error}))))]
|
||||
|
||||
[:> dropdown-menu* {:show show
|
||||
:on-close on-close
|
||||
@ -284,37 +237,23 @@
|
||||
(mf/defc organization-context-menu*
|
||||
"Right-click menu on an organization in `organizations-column*`,
|
||||
currently offering just \"leave organization\". Split out of
|
||||
`organization-team-switch*` so its leave-organization data (owned
|
||||
teams, transfer candidates, member loading) stays local instead of
|
||||
being computed unconditionally on every render of the switcher."
|
||||
`organization-team-switch*` so its leave-organization data stays
|
||||
local instead of being computed on every render of the switcher."
|
||||
[{:keys [organization teams profile x y on-close on-leave-requested]}]
|
||||
(let [{default-team-id :default-team-id
|
||||
owned-teams :owned-teams
|
||||
teams-to-transfer :teams-to-transfer
|
||||
leave-fn :leave-fn}
|
||||
(use-organization-leave organization teams true)
|
||||
|
||||
owned-teams-members-loaded?
|
||||
(every? #(contains? % :members) owned-teams)
|
||||
|
||||
on-leave-clicked
|
||||
(mf/use-fn
|
||||
(mf/deps leave-fn
|
||||
profile
|
||||
organization
|
||||
default-team-id
|
||||
teams-to-transfer
|
||||
owned-teams-members-loaded?
|
||||
on-leave-requested)
|
||||
(mf/deps leave-fn profile organization default-team-id on-leave-requested)
|
||||
(fn []
|
||||
(when owned-teams-members-loaded?
|
||||
(on-leave-requested)
|
||||
(st/emit! (dnt/show-leave-organization-modal {:organization organization
|
||||
:profile profile
|
||||
:default-team-id default-team-id
|
||||
:leave-fn leave-fn
|
||||
:teams-to-transfer teams-to-transfer
|
||||
:on-error dnt/org-leave-on-error})))))]
|
||||
(on-leave-requested)
|
||||
(st/emit! (dnt/show-leave-organization-modal {:organization organization
|
||||
:profile profile
|
||||
:default-team-id default-team-id
|
||||
:leave-fn leave-fn
|
||||
:on-error dnt/org-leave-on-error}))))]
|
||||
|
||||
[:> dropdown-menu* {:show true
|
||||
:on-close on-close
|
||||
|
||||
@ -14,10 +14,12 @@
|
||||
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.team :as dt]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.auth.verify-token :as verify-token]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(t/deftest account-age-days-test
|
||||
@ -270,31 +272,20 @@
|
||||
(t/is (= ["t1" "t4"] (map :id (dnt/organization-teams teams "org-a"))))
|
||||
(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}
|
||||
{:id "owned-1" :permissions {:is-owner true}}
|
||||
{:id "owned-2" :permissions {:is-owner true}}
|
||||
{:id "member-1" :permissions {:is-owner false}}]
|
||||
info (dnt/organization-leave-info org-teams)]
|
||||
(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/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
|
||||
(let [captured (atom nil)
|
||||
emitted (atom [])
|
||||
owned-teams [{:id "solo" :members [{:id "m1"}]}
|
||||
{:id "pair" :members [{:id "m1"} {:id "m2"}]}]
|
||||
not-owned-teams [{:id "member-1" :name "extra"}]
|
||||
leave-fn (dnt/leave-organization-fn {:organization {:id "org-1" :name "Acme"}
|
||||
:default-team-id "default"
|
||||
:owned-teams owned-teams
|
||||
:not-owned-teams not-owned-teams
|
||||
:on-error :on-error-fn})]
|
||||
(with-redefs [dnt/leave-organization (fn [params] (reset! captured params) ::leave-event)
|
||||
@ -304,6 +295,7 @@
|
||||
|
||||
(t/testing "with no teams offered for transfer"
|
||||
(leave-fn {:teams-to-transfer nil
|
||||
:teams-to-delete ["solo"]
|
||||
:member-added-at "2026-07-17T00:00:00Z"
|
||||
:organization-member-count-before 3})
|
||||
|
||||
@ -327,6 +319,45 @@
|
||||
{:id "member-1"}]
|
||||
(: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/testing "known error code shows a translated notification"
|
||||
(let [emitted (atom [])]
|
||||
|
||||
@ -57,16 +57,16 @@
|
||||
(t/testing "profile with no organizations at all: line absent"
|
||||
(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"}
|
||||
"org-b" {:id "org-b" :name "Org B" :default-team-id "team-b-default"}
|
||||
nil 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-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/deftest teams-for-organization-filters-and-sorts-per-organization
|
||||
@ -117,6 +117,22 @@
|
||||
(t/testing "flag absent: fallback action hidden"
|
||||
(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/testing "owner of the previewed organization: organization-specific href"
|
||||
(let [organization {:id "org-a" :slug "org-a-slug" :owner-id "profile-1"}
|
||||
|
||||
@ -10931,4 +10931,4 @@ msgid "labels.team-management"
|
||||
msgstr "Team management"
|
||||
|
||||
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"
|
||||
|
||||
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