🎉 Add combined organization/team switcher to the dashboard (#11760)

* 🎉 Add combined organization/team switcher to the dashboard

* 📎 Code review
This commit is contained in:
María Valderrama 2026-09-21 12:50:32 +02:00 committed by GitHub
parent 9fa0d9c996
commit b32a73519e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1761 additions and 1169 deletions

View File

@ -168,7 +168,7 @@ test("Bug 10141, The team does not disappear from the team list after deletion",
await dashboardPage.teamDropdown.click();
await expect(page.getByText("Second Team")).toBeVisible();
await page.getByText("Second Team").click();
await page.getByRole("button", { name: "team-management" }).click();
await page.getByTestId("team-options-button").click();
await page.getByTestId("delete-team").click();
await DashboardPage.mockRPC(

View File

@ -227,7 +227,7 @@ test("User opens team management dropdown", async ({ page }) => {
await dashboardPage.goToSecondTeamDashboard();
await expect(page.getByText("Team Up")).toBeVisible();
await page.getByRole("button", { name: "team-management" }).click();
await page.getByTestId("team-options-button").click();
await expect(page.getByTestId("team-members")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2h-4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8"/>
<path d="M16.706 2.706A2.4 2.4 0 0 0 15 2v5a1 1 0 0 0 1 1h5a2.4 2.4 0 0 0-.706-1.706z"/>
<path d="M5 7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 1.732-1"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@ -73,19 +73,24 @@
(cond-> (u/join public-uri "admin-console/" path)
(seq query-params) (assoc :query (u/map->query-string query-params))))))
(defn go-to-nitrate-ac
(defn build-admin-console-href
([]
(st/emit! (rt/nav-raw :href (build-admin-console-url ""))))
(build-admin-console-url ""))
([{:keys [organization-id organization-slug]}]
(if (and organization-id organization-slug)
(let [path (dm/str "organization/"
(u/percent-encode organization-slug)
"/"
(u/percent-encode (str organization-id))
"/people/")
href (build-admin-console-url path)]
(st/emit! (rt/nav-raw :href href)))
(st/emit! (rt/nav-raw :href (build-admin-console-url ""))))))
"/people/")]
(build-admin-console-url path))
(build-admin-console-url ""))))
(defn go-to-nitrate-ac
([]
(st/emit! (rt/nav-raw :href (build-admin-console-href))))
([options]
(st/emit! (rt/nav-raw :href (build-admin-console-href options)))))
(defn go-to-nitrate-ac-create-organization
[event-origin]
@ -164,6 +169,58 @@
(contains? #{"active" "past_due" "trialing"}
(dm/get-in profile [:subscription :status]))))
(defn organization-teams
"Teams belonging to `organization-id`, out of the full team map."
[teams organization-id]
(->> teams
vals
(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)."
[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))
(def ^:private team-leave-error-messages
{:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete"
:no-enough-members-for-leave "errors.team-leave.insufficient-members"
:member-does-not-exist "errors.team-leave.member-does-not-exists"
:owner-cant-leave-team "errors.team-leave.owner-cant-leave"})
(defn team-leave-on-error
[error]
(let [code (-> error ex-data :code)]
(if-let [tr-key (get team-leave-error-messages code)]
(rx/of (ntf/error (tr tr-key)))
(rx/throw error))))
(def ^:private organization-leave-error-messages
(merge team-leave-error-messages
{:not-valid-teams "errors.organization-leave.no-valid-teams"
:organization-owner-cannot-leave "errors.organization-leave.organization-owner-cannot-leave"}))
(defn org-leave-on-error
[error]
(let [code (-> error ex-data :code)]
(if-let [tr-key (get organization-leave-error-messages code)]
(rx/of (dt/fetch-teams)
(modal/hide)
(ntf/error (tr tr-key)))
(rx/throw error))))
(defn leave-organization
[{:keys [id
name
@ -210,6 +267,31 @@
:level :success}))))
(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]}]
(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))]
(st/emit! (leave-organization {:id (:id organization)
:name (:name organization)
:default-team-id default-team-id
:teams-to-delete teams-to-delete
:teams-to-leave teams-to-leave
:member-added-at member-added-at
:organization-member-count-before organization-member-count-before
:on-error on-error})))))
(defn show-leave-organization-modal
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
(ptk/reify ::show-leave-organization-modal

View File

@ -27,7 +27,7 @@
(mf/defc internal-dropdown-menu*
{::mf/private true}
[{:keys [on-close children class id on-pointer-enter on-pointer-leave]}]
[{:keys [on-close children class id style on-pointer-enter on-pointer-leave]}]
(assert (fn? on-close) "missing `on-close` prop")
@ -109,6 +109,7 @@
[:ul {:class class
:role "menu"
:ref container
:style style
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
children]))

View File

@ -0,0 +1,599 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.dashboard.organization-team-switch
"A single two-level dropdown that merges the organization switcher
and the team switcher: the left column lists the user's
organizations, the right column lists the teams that belong to the
organization currently selected on the left."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.team :as dtm]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.components.organization-avatar :refer [organization-avatar*]]
[app.main.ui.dashboard.organization-team-switch-menus :refer [options-dropdown*
organization-context-menu*]]
[app.main.ui.dashboard.subscription :refer [get-subscription-type
menu-team-icon*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle")
;; Sentinel used for the "personal projects" bucket, since those teams
;; have no organization (`:organization` is `nil`).
(def ^:private personal-bucket-id :personal)
(defn organization-bucket-id
[organization]
(or (:id organization) personal-bucket-id))
(defn team-href
[router team]
(rt/resolve-uri router :dashboard-recent {:team-id (:id team)}))
(defn- team-display-name
[team]
(if (:is-default team) (tr "dashboard.personal-projects") (:name 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
(\"Personal projects\") always last. `boolean` collapses a missing
`:is-default` and an explicit `false` into one comparator value."
[teams]
(sort-by (juxt (fn [team] (boolean (:is-default team)))
(fn [team] (str/lower (:name team ""))))
teams))
(defn sort-all-teams
"Orders every team for the simplified single-column dropdown:
alphabetical by display name, with \"Personal projects\" always
last. Takes the display-name fn as a parameter so it stays pure;
the component passes `team-display-name`, which translates the
default team's label."
([teams]
(sort-all-teams teams team-display-name))
([teams display-name]
(sort-by (juxt (fn [team] (boolean (:is-default team)))
(comp str/lower display-name))
teams)))
(defn teams-for-organization
"Teams to preview in the dropdown's second column for
`organization-id`: teams with no organization of their own when
`organization-id` is the personal bucket, otherwise the teams
belonging to that organization, sorted for display."
[teams organization-id]
(->> teams
vals
(filter (fn [candidate-team]
(if (= organization-id personal-bucket-id)
(nil? (dm/get-in candidate-team [:organization :id]))
(= organization-id (dm/get-in candidate-team [:organization :id])))))
sort-organization-teams))
(defn sort-organizations
"Orders the organizations for the dropdown's first column:
alphabetical, with the \"Other teams\" bucket (no id) always last;
organizations sharing a name tie-break on id so the order is
stable."
[organizations]
(sort-by (juxt (fn [organization] (if (nil? (:id organization)) 1 0))
(fn [organization] (str/lower (:name organization "")))
:id)
organizations))
(defn closed-control-line-2
"The organization name for the second line of the closed switcher
control, or nil when the line must not be rendered at all: the
profile belongs to no organization, or the current destination
(personal projects, or a team under the \"Other teams\" bucket) has
no organization of its own."
[has-organizations? current-organization]
(when has-organizations?
(:name current-organization)))
(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."
[organizations organization-id]
(when-not (= organization-id personal-bucket-id)
(:default-team-id (get organizations organization-id))))
(defn team-select-target
"The team id to navigate to when a team row is selected, or nil
when selecting is a no-op beyond closing the switcher because the
user is already in that team."
[selected-team-id current-team]
(when (not= selected-team-id (:id current-team))
selected-team-id))
(defn show-create-organization-in-teams-column?
"Whether the simplified single-column dropdown should offer its own
\"Create new organization\" fallback action: only on deployments
where the admin-console (Nitrate) feature exists at all. Mirrors
the pre-merge sidebar's top-level `(when nitrate? ...)` gate, which
used to hide the whole organization-switcher UI (including this
action) on installs without the flag."
[flags]
(contains? flags :admin-console))
(defn resolve-admin-console-href
"The admin-console link for the pinned action at the bottom of the
organizations column: the organization-specific page when `profile`
owns `organization`, the generic admin-console page otherwise —
including when no organization is previewed at all (e.g. \"Other
teams\"/personal projects is selected)."
[organization profile]
(if (and (:id organization) (= (:id profile) (:owner-id organization)))
(dnt/build-admin-console-href {:organization-id (:id organization)
:organization-slug (:slug organization)})
(dnt/build-admin-console-href)))
(defn- new-tab-click?
[event]
(or (kbd/mod? event) (dom/middle-mouse? event)))
(mf/defc organizations-column*
{::mf/private true}
[{:keys [organizations selected-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)}
(tr "dashboard.section.organizations")]
[:ul {:class (stl/css :column-list)}
(when-not has-organizations?
[:li {:class (stl/css :empty-state)}
(tr "dashboard.no-organizations-yet")])
(for [organization organizations]
(let [bucket-id (organization-bucket-id organization)
personal? (= bucket-id personal-bucket-id)]
[:* {:key (str bucket-id)}
;; "Other teams" is always last (see `sort-organizations`),
;; set apart from the real organizations above it the same
;; way `.column-separator` sets the pinned actions apart
;; from the list.
(when personal?
[:li {:role "separator" :class (stl/css :column-separator)}])
[:> dropdown-menu-item* {:data-value (str bucket-id)
:class (stl/css-case :organization-item true
:selected (= bucket-id selected-id))
:on-click on-select
:on-context-menu #(on-context-menu % organization)}
(if personal?
[:span {:class (stl/css :my-teams-icon)}
[:> raw-svg* {:id penpot-logo-icon-subtle}]]
[:> organization-avatar* {:organization organization :size "xxl"}])
[:span {:class (stl/css :organization-text-group)}
[: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)
[:span {:class (stl/css :tick-icon)}
[:> icon* {:icon-id i/tick :size "s"}]])]
[:span {:class (stl/css :chevron-icon)}
[:> icon* {:icon-id i/arrow-right :size "s"}]]]]))]
[:ul {:class (stl/css :column-actions)}
[:li {:role "separator" :class (stl/css :column-separator)}]
[:> dropdown-menu-item* {:on-click on-create-organization
:class (stl/css :organization-item :action)}
[:span {:class (stl/css :icon-wrapper)}
[:> icon* {:icon-id i/add :class (stl/css :action-icon)}]]
[:span {:class (stl/css :organization-text)} (tr "dashboard.create-new-organization")]]
(when valid-license
[:> dropdown-menu-item* {:class (stl/css :organization-item :action :with-link)
:on-click (fn [event]
(if (new-tab-click? event)
(dom/stop-propagation event)
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (rt/nav-raw :href admin-console-href)))))
:on-context-menu on-dismiss-context-menu}
[:a {:class (stl/css :item-link)
:href admin-console-href
:tab-index "-1"}
[:span {:class (stl/css :icon-wrapper)}
[:> icon* {:icon-id i/arrow-up-right :class (stl/css :action-icon)}]]
[:span {:class (stl/css :organization-text)} (tr "dashboard.go-to-admin-console")]]])]])
(mf/defc teams-column*
{::mf/private true}
[{:keys [teams selected-team-id on-select on-context-menu on-create-team
on-create-organization]}]
(let [router (mf/deref refs/router)]
[:li {:role "presentation" :class (stl/css :teams-column)}
[:div {:class (stl/css :column-label)}
(tr "dashboard.section.teams")]
[:ul {:class (stl/css :column-list)}
(for [team teams]
(let [subscription-type (get-subscription-type (:subscription team))]
[:* {:key (str (:id team))}
;; "Personal projects" is always last (see
;; `sort-organization-teams`/`sort-all-teams`), set apart
;; from the real teams above it the same way
;; `.column-separator` sets the pinned actions apart from
;; the list. Skipped when it's the only team: there's
;; nothing above it to separate from.
(when (and (:is-default team) (> (count teams) 1))
[:li {:role "separator" :class (stl/css :column-separator)}])
[:> dropdown-menu-item* {:data-value (str (:id team))
:class (stl/css-case :team-item true
:with-link true)
:on-click on-select
:on-context-menu on-context-menu}
[:a {:class (stl/css :item-link)
:href (team-href router team)
:tab-index "-1"}
(if (:is-default team)
[:span {:class (stl/css :team-item-personal-icon)}
[:> icon* {:icon-id i/files}]]
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-item-picture)
:alt (:name team)}])
[:span {:class (stl/css :team-text-group)}
[:span {:class (stl/css :team-text)
:title (team-display-name team)}
(team-display-name team)]
(when (#{"unlimited" "enterprise"} subscription-type)
[:> menu-team-icon* {:subscription-type subscription-type}])
(when (= (:id team) selected-team-id)
[:span {:class (stl/css :tick-icon)}
[:> icon* {:icon-id i/tick :size "s"}]])]]]]))]
[:ul {:class (stl/css :column-actions)}
[:li {:role "separator" :class (stl/css :column-separator)}]
[:> dropdown-menu-item* {:on-click on-create-team
:class (stl/css :team-item :action)}
[:span {:class (stl/css :icon-wrapper)}
[:> icon* {:icon-id i/add :class (stl/css :action-icon)}]]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]
;; Only present when there is no separate organizations column to
;; host it (no valid license and no organization membership).
(when on-create-organization
[:> dropdown-menu-item* {:on-click on-create-organization
:class (stl/css :team-item :action)}
[:span {:class (stl/css :icon-wrapper)}
[:> icon* {:icon-id i/add :class (stl/css :action-icon)}]]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-organization")]])]]))
(mf/defc organization-team-switch*
[{:keys [team profile]}]
(let [teams (mf/deref refs/teams)
current-organization (dtm/team->organization team)
current-organization-id (organization-bucket-id current-organization)
;; The organization owner cannot leave their own organization
;; from the team options menu.
can-leave-organization?
(and (:id current-organization)
(not= (:id profile) (:owner-id current-organization)))
;; A default team (the personal "my teams" bucket, or an
;; organization's own default team) has no options of its own
;; to manage; the "..." button is only worth showing when
;; there is at least a "leave organization" action behind it.
show-team-options-button?
(or (not (:is-default team)) can-leave-organization?)
subscription-type (get-subscription-type (-> profile :props :subscription))
current-team-subscription-type (get-subscription-type (:subscription team))
team-count (count teams)
account-age-days (dnt/account-age-days profile)
is-valid-license? (dnt/is-valid-license? profile)
;; Keyed by `organization-bucket-id`, not raw `:id`: every
;; personal default team maps to a nil organization, so
;; indexing by `:id` would key them all under a bare `nil`
;; that happens to collide with the map's own "no value"
;; result. Keying by the bucket id instead makes the
;; "personal/other-teams" entry explicit and gives it a
;; lookup key consistent with `selected-organization-id` and
;; `create-team-target-id`, which already reason in terms of
;; `personal-bucket-id`.
organizations
(mf/with-memo [teams current-organization]
(cond-> (->> teams
vals
(filter :is-default)
(map dtm/team->organization)
(d/index-by organization-bucket-id))
(:id current-organization)
(assoc (:id current-organization) current-organization)))
has-organizations?
(some #(not= personal-bucket-id %) (keys organizations))
;; Without a valid license and without belonging to any real
;; organization, there is nothing to put in an organizations
;; column at all: fall back to a single teams-only column,
;; with "create organization" folded into it (see
;; `teams-column*`'s `on-create-organization`).
simplified-mode?
(and (not is-valid-license?) (not has-organizations?))
all-teams-sorted
(mf/with-memo [teams]
(sort-all-teams (vals teams)))
show-menu*
(mf/use-state false)
show-menu?
(deref show-menu*)
show-options-menu*
(mf/use-state false)
show-options-menu?
(deref show-options-menu*)
;; Which organization is previewed on the right column; reset
;; to the current one every time the menu is (re)opened.
selected-organization-id*
(mf/use-state current-organization-id)
selected-organization-id
(deref selected-organization-id*)
;; Context menu state for right-click on organizations
context-menu*
(mf/use-state nil)
context-menu
(deref context-menu*)
selected-organization-teams
(mf/with-memo [teams selected-organization-id]
(teams-for-organization teams selected-organization-id))
on-open-click
(mf/use-fn
(mf/deps current-organization-id)
(fn [event]
(dom/stop-propagation event)
(reset! selected-organization-id* current-organization-id)
(swap! show-menu* not)))
on-close
(mf/use-fn #(do (reset! show-menu* false) (reset! context-menu* nil)))
on-close-options
(mf/use-fn #(reset! show-options-menu* false))
on-show-options-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-options-menu* not)))
on-organization-select
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [value (-> (dom/get-current-target event) (dom/get-data "value"))]
(reset! selected-organization-id*
(if (= value (str personal-bucket-id))
personal-bucket-id
(uuid/parse value))))))
on-team-select
(mf/use-fn
(mf/deps team)
(fn [event]
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(if (new-tab-click? event)
(dom/stop-propagation event)
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(reset! show-menu* false)
(when-let [target-team-id (team-select-target team-id team)]
(st/emit! (dcm/go-to-dashboard-recent :team-id target-team-id))))))))
on-create-organization
(mf/use-fn
(mf/deps account-age-days profile subscription-type team-count)
(fn []
(reset! show-menu* false)
(if (and (not= subscription-type "unlimited") is-valid-license?)
(dnt/go-to-nitrate-ac-create-organization
"dashboard:organization-switcher")
(st/emit!
(ev/event
(cond-> {::ev/name "open-subscription-modal"
::ev/origin "dashboard:organization-switcher"
:product "nitrate:enterprise"
:source "frontend"
:has-teams (pos? team-count)
:team-count team-count}
(some? account-age-days)
(assoc :account-age-days account-age-days)))
(dnt/show-nitrate-popup
:nitrate-form
(cond-> {:subscription-start-origin "dashboard:organization-switcher"}
(= subscription-type "unlimited")
(assoc :show-contact-sales-option true)))))))
;; "Go to admin console" targets the organization currently
;; previewed on the left column (not necessarily the active
;; team's organization), and only owners of that organization
;; get the option at all.
selected-organization
(get organizations selected-organization-id)
admin-console-href
(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.
on-create-team
(mf/use-fn
(mf/deps organizations current-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)))
(st/emit! (modal/show :team-form {})))))
on-close-context-menu
(mf/use-fn #(reset! context-menu* nil))
;; The organization owner cannot leave their own organization,
;; which is the only action offered here, so right-clicking an
;; owned organization (or the personal-projects bucket, which
;; has none) never opens a menu; it just dismisses whichever
;; one might already be open, same as clicking anywhere else.
on-organization-context-menu
(mf/use-fn
(mf/deps profile on-close-context-menu)
(fn [event organization]
(dom/stop-propagation event)
(if (and (:id organization)
(not= (:id profile) (:owner-id organization)))
(do
(dom/prevent-default event)
(reset! context-menu* {:organization organization
:x (.-clientX event)
:y (.-clientY event)}))
(on-close-context-menu))))
;; Right-clicking a row with no context menu of its own (team
;; rows, the admin-console link) still needs to dismiss any
;; already-open organization context menu: `stop-propagation`
;; here keeps the two-column dropdown itself open, but it also
;; prevents that click from ever reaching the document-level
;; listener the organization context menu relies on to close
;; itself, so it has to be closed explicitly instead.
on-dismiss-context-menu
(mf/use-fn
(mf/deps on-close-context-menu)
(fn [event]
(dom/stop-propagation event)
(on-close-context-menu)))
on-organization-leave-requested
(mf/use-fn #(do (reset! context-menu* nil) (reset! show-menu* false)))]
[:div {:class (stl/css :organization-team-switch)}
[:div {:class (stl/css :switch-button-row)}
[:button {:class (stl/css-case :current-selection true
:no-options-button (not show-team-options-button?))
:on-click on-open-click
:aria-expanded show-menu?
:aria-haspopup "menu"}
(if (:is-default team)
[:div {:class (stl/css :personal-projects-icon)}
[:> icon* {:icon-id i/files}]]
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}])
[:div {:class (stl/css :current-selection-text)}
[:div {:class (stl/css :current-team-name-group)}
[:span {:class (stl/css :current-team-name)
:title (team-display-name team)}
(team-display-name team)]
(when (#{"unlimited" "enterprise"} 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)]
[:span {:class (stl/css :current-organization-name)
:title organization-name}
organization-name])]]
(when show-team-options-button?
[:button {:class (stl/css :options-button)
:on-click on-show-options-click
:aria-expanded show-options-menu?
:aria-haspopup "menu"
:aria-label (tr "labels.team-management")
:data-testid "team-options-button"}
[:> icon* {:icon-id i/menu :class (stl/css :options-icon)}]])
(if simplified-mode?
[:> dropdown-menu* {:show show-menu?
:on-close on-close
:id "organization-team-switch"
:class (stl/css :organization-team-dropdown :single-column)}
[:> teams-column* {:teams all-teams-sorted
:selected-team-id (:id team)
:on-select on-team-select
:on-context-menu on-dismiss-context-menu
:on-create-team on-create-team
:on-create-organization (when (show-create-organization-in-teams-column? cf/flags)
on-create-organization)}]]
[:> dropdown-menu* {:show show-menu?
:on-close on-close
:id "organization-team-switch"
:class (stl/css :organization-team-dropdown)}
[:> organizations-column* {:organizations (sort-organizations (vals organizations))
:selected-id selected-organization-id
:has-organizations? has-organizations?
:on-select on-organization-select
:on-create-organization on-create-organization
:admin-console-href admin-console-href
:valid-license is-valid-license?
:on-context-menu on-organization-context-menu
:on-dismiss-context-menu on-dismiss-context-menu}]
[:> teams-column* {:teams selected-organization-teams
:selected-team-id (:id team)
:on-select on-team-select
:on-context-menu on-dismiss-context-menu
:on-create-team on-create-team}]])]
[:> options-dropdown* {:show show-options-menu?
:on-close on-close-options
:id "team-options"
:class (stl/css :options-dropdown)
:team team
:profile profile
:current-organization current-organization
:can-leave-organization can-leave-organization?
:teams teams}]
;; Context menu for leave organization
(when context-menu
[:> organization-context-menu* {:organization (:organization context-menu)
:teams teams
:profile profile
:x (:x context-menu)
:y (:y context-menu)
:on-close on-close-context-menu
:on-leave-requested on-organization-leave-requested}])]))

View File

@ -0,0 +1,405 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/typography.scss" as t;
@use "ds/colors.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/z-index.scss" as *;
@use "ds/mixins.scss" as *;
.organization-team-switch {
position: relative;
width: 100%;
padding: var(--sp-s);
}
.switch-button-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--sp-xxs);
height: $sz-64;
width: 100%;
border-radius: $br-8;
}
.current-selection {
border: none;
background: none;
cursor: pointer;
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
gap: var(--sp-s);
height: 100%;
width: 100%;
padding: 0 var(--sp-m) 0 var(--sp-l);
border-radius: $br-8 0 0 $br-8;
background-color: var(--menu-background-color);
&:hover {
background-color: var(--menu-background-color-hover);
}
}
.current-selection.no-options-button {
border-radius: $br-8;
}
.options-button {
border: none;
background: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: $sz-40;
padding: 0;
border-radius: 0 $br-8 $br-8 0;
color: var(--icon-foreground);
background-color: var(--menu-background-color);
&:hover {
background-color: var(--menu-background-color-hover);
color: var(--color-accent-primary);
}
}
.options-icon {
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
color: transparent;
fill: none;
stroke-width: 1px;
stroke: var(--icon-foreground);
}
.current-selection-text {
min-width: 0;
}
.current-team-name-group {
display: flex;
align-items: center;
gap: var(--sp-s);
min-width: 0;
max-width: 100%;
}
.current-team-name {
@include text-ellipsis;
@include t.use-typography("body-large");
min-width: 0;
flex-shrink: 1;
color: var(--menu-foreground-color-hover);
}
.current-organization-name {
@include text-ellipsis;
@include t.use-typography("body-small");
max-width: 100%;
color: var(--color-foreground-secondary);
text-align: left;
}
.tick-icon,
.chevron-icon {
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
}
.tick-icon svg,
.chevron-icon svg {
color: transparent;
fill: none;
stroke-width: 1px;
stroke: var(--icon-foreground);
}
.my-teams-icon {
height: var(--sp-xxl);
width: var(--sp-xxl);
svg {
fill: var(--icon-stroke-color);
width: var(--sp-xxl);
height: var(--sp-xxl);
}
}
.team-item-personal-icon {
display: flex;
justify-content: center;
align-items: center;
height: var(--sp-xxl);
width: var(--sp-xxl);
}
.team-item-personal-icon svg {
color: transparent;
fill: none;
stroke-width: 1px;
stroke: var(--icon-foreground);
}
.team-item-picture {
display: flex;
justify-content: center;
align-items: center;
border-radius: $br-circle;
height: var(--sp-xxl);
width: var(--sp-xxl);
}
.personal-projects-icon {
display: flex;
justify-content: center;
align-items: center;
height: var(--sp-xxxl);
width: var(--sp-xxxl);
}
.personal-projects-icon svg {
color: transparent;
fill: none;
stroke-width: 1px;
stroke: var(--icon-foreground);
}
.team-picture {
display: flex;
justify-content: center;
align-items: center;
border-radius: $br-circle;
height: var(--sp-xxxl);
width: var(--sp-xxxl);
}
.organization-team-dropdown {
box-shadow: 0 0 var(--sp-m) 0 var(--menu-shadow-color);
position: absolute;
padding: var(--sp-xs);
border-radius: $br-8;
z-index: var(--z-index-dropdown);
color: var(--title-foreground-color-hover);
background-color: var(--menu-background-color);
border: $b-2 solid var(--panel-border-color);
margin: 0;
display: grid;
grid-template-columns: px2rem(257) px2rem(257);
grid-template-rows: minmax(0, 1fr);
gap: var(--sp-m);
top: calc(var(--sp-s) + #{$sz-64} + var(--sp-xs));
left: var(--sp-s);
height: fit-content;
max-height: px2rem(416);
overflow: hidden;
}
.organization-team-dropdown.single-column {
grid-template-columns: px2rem(267);
max-height: $sz-520;
overflow-y: auto;
.teams-column {
border-inline-start: none;
padding-inline-start: 0;
}
}
.organizations-column,
.teams-column {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--sp-xxs);
margin: 0;
padding: 0;
min-width: 0;
min-height: 0;
list-style: none;
}
.teams-column {
border-inline-start: $b-1 solid var(--color-background-quaternary);
padding-inline-start: var(--sp-m);
}
.column-label {
@include t.use-typography("headline-small");
color: var(--color-foreground-secondary);
padding: $sz-6 $sz-6 $sz-6 var(--sp-m);
text-transform: uppercase;
}
.column-list,
.column-actions {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
margin: 0;
padding: 0;
list-style: none;
}
.column-list {
@include custom-scrollbar;
overflow-y: auto;
min-height: 0;
background:
linear-gradient(transparent, var(--menu-background-color) 70%) 0 100% / 100% #{$sz-40} local no-repeat,
radial-gradient(farthest-side at 50% 100%, var(--menu-shadow-color), transparent) 0 100% / 100% #{$sz-14}
scroll no-repeat;
background-color: var(--menu-background-color);
}
.column-separator {
border-block-start: $b-1 solid var(--color-background-quaternary);
margin-block: var(--sp-xxs);
list-style: none;
}
.empty-state {
@include t.use-typography("body-medium");
padding: var(--sp-m);
color: var(--color-foreground-secondary);
list-style: none;
}
.organization-item {
padding-inline-end: var(--sp-m);
}
.team-item {
padding-inline-end: var(--sp-s);
}
.organization-item,
.team-item {
@include t.use-typography("body-small");
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--sp-s);
height: $sz-40;
flex-shrink: 0;
padding-inline-start: var(--sp-m);
border-radius: $br-8;
cursor: pointer;
&:hover {
background-color: var(--menu-background-color-hover);
}
}
.organization-item.with-link,
.team-item.with-link {
display: block;
padding-inline-start: 0;
}
.item-link,
.item-link:hover {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: var(--sp-s);
height: 100%;
padding-inline-start: var(--sp-m);
text-decoration: none;
color: inherit;
}
.organization-text-group,
.team-text-group {
display: flex;
align-items: center;
gap: var(--sp-s);
min-width: 0;
}
.organization-text,
.team-text {
@include text-ellipsis;
min-width: 0;
text-align: left;
color: var(--menu-foreground-color-hover);
}
.organization-text,
.team-text {
flex-shrink: 1;
}
.selected {
background-color: var(--menu-background-color-hover);
}
.action {
--sidebar-action-icon-color: var(--icon-foreground);
--sidebar-icon-backgroun-color: var(--color-background-secondary);
&:hover {
--sidebar-action-icon-color: var(--color-background-secondary);
--sidebar-icon-backgroun-color: var(--color-accent-primary);
}
}
.icon-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: var(--sp-xxl);
height: var(--sp-xxl);
border-radius: $br-circle;
background-color: var(--sidebar-icon-backgroun-color);
}
.action-icon {
display: flex;
justify-content: center;
align-items: center;
height: $sz-16;
width: $sz-16;
color: transparent;
fill: none;
stroke-width: 1px;
stroke: var(--sidebar-action-icon-color);
}
.options-dropdown {
box-shadow: 0 0 var(--sp-m) 0 var(--menu-shadow-color);
position: absolute;
padding: var(--sp-xs);
border-radius: $br-8;
z-index: var(--z-index-dropdown);
color: var(--title-foreground-color-hover);
background-color: var(--menu-background-color);
border: $b-2 solid var(--panel-border-color);
margin: 0;
top: calc(var(--sp-s) + #{$sz-64} + var(--sp-xs));
right: var(--sp-s);
min-width: $sz-192;
height: fit-content;
max-height: $sz-480;
overflow-y: auto;
}

View File

@ -0,0 +1,327 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.dashboard.organization-team-switch-menus
"The team-options dropdown and the organization right-click menu
that `organization-team-switch*` opens, split out of
`app.main.ui.dashboard.organization-team-switch` to keep that file
focused on the switcher's own two-column dropdown. Not meant to be
reused outside that component."
(:require-macros [app.main.style :as stl])
(:require
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.team :as dtm]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.util.i18n :refer [tr]]
[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."
[organization teams ^boolean enabled?]
(let [org-teams
(mf/with-memo [teams organization enabled?]
(when enabled?
(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)
(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*
"The \"...\" team-management menu opened from the switcher's closed
control: team members/invitations/webhooks/settings/rename/leave/
delete, plus \"leave organization\" when `can-leave-organization`."
[{:keys [show id class team profile current-organization teams ^boolean can-leave-organization on-close]}]
(let [members (get team :members)
permissions (get team :permissions)
can-rename? (or (:is-owner permissions) (:is-admin permissions))
default-team-id (:default-team-id profile)
;; A default team (the personal "my teams" bucket, or an
;; organization's own default team) isn't a real, manageable
;; team: it has no separate members/settings/etc. of its own,
;; so none of those options apply to it.
show-team-management?
(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)
(modal/hide)))
leave-fn
(mf/use-fn
(mf/deps on-success)
(fn [member-id]
(let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))]
(st/emit! (dtm/leave-current-team (with-meta params
{:on-success on-success
:on-error dnt/team-leave-on-error}))))))
delete-fn
(mf/use-fn
(mf/deps team on-success)
(fn []
(st/emit! (dtm/delete-team (with-meta team {:on-success on-success
:on-error dnt/team-leave-on-error})))))
on-members-click
(mf/use-fn #(do (on-close) (st/emit! (dcm/go-to-dashboard-members))))
on-invitations-click
(mf/use-fn #(do (on-close) (st/emit! (dcm/go-to-dashboard-invitations))))
on-webhooks-click
(mf/use-fn #(do (on-close) (st/emit! (dcm/go-to-dashboard-webhooks))))
on-settings-click
(mf/use-fn #(do (on-close) (st/emit! (dcm/go-to-dashboard-settings))))
on-rename-clicked
(mf/use-fn
(mf/deps team)
(fn []
(on-close)
(st/emit! (modal/show :team-form {:team team}))))
on-leave-clicked
(mf/use-fn
(mf/deps leave-fn)
(fn []
(on-close)
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-confirm.message")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept leave-fn}))))
on-leave-as-owner-clicked
(mf/use-fn
(mf/deps profile team leave-fn)
(fn []
(on-close)
(st/emit! (dtm/fetch-members)
(modal/show
{:type :leave-and-reassign
:profile profile
:team team
:accept leave-fn}))))
leave-and-close
(mf/use-fn
(mf/deps team delete-fn)
(fn []
(on-close)
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-and-close-confirm.message" (:name team))
:scd-message (tr "modals.leave-and-close-confirm.hint")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept delete-fn}))))
on-delete-clicked
(mf/use-fn
(mf/deps team delete-fn)
(fn []
(on-close)
(st/emit! (dtm/check-and-delete-team {:team-id (:id team)
:delete-fn delete-fn}))))
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?)
(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})))))]
[:> dropdown-menu* {:show show
:on-close on-close
:id id
:class class}
(when show-team-management?
[:*
[:> dropdown-menu-item* {:on-click on-members-click
:class (stl/css :options-item)
:data-testid "team-members"}
(tr "labels.members")]
[:> dropdown-menu-item* {:on-click on-invitations-click
:class (stl/css :options-item)
:data-testid "team-invitations"}
(tr "labels.invitations")]
(when (contains? cf/flags :webhooks)
[:> dropdown-menu-item* {:on-click on-webhooks-click
:class (stl/css :options-item)}
(tr "labels.webhooks")])
[:> dropdown-menu-item* {:on-click on-settings-click
:class (stl/css :options-item)
:data-testid "team-settings"}
(tr "labels.settings")]
[:hr {:class (stl/css :options-separator)}]
(when can-rename?
[:> dropdown-menu-item* {:on-click on-rename-clicked
:class (stl/css :options-item)
:data-testid "rename-team"}
(tr "labels.rename")])
(cond
(= (count members) 1)
[:> dropdown-menu-item* {:on-click leave-and-close
:class (stl/css :options-item)}
(tr "dashboard.leave-team")]
(:is-owner permissions)
[:> dropdown-menu-item* {:on-click on-leave-as-owner-clicked
:class (stl/css :options-item)
:data-testid "leave-team"}
(tr "dashboard.leave-team")]
(> (count members) 1)
[:> dropdown-menu-item* {:on-click on-leave-clicked
:class (stl/css :options-item)}
(tr "dashboard.leave-team")])
(when (:is-owner permissions)
[:> dropdown-menu-item* {:on-click on-delete-clicked
:class (stl/css :options-item :warning)
:data-testid "delete-team"}
(tr "dashboard.delete-team")])])
(when can-leave-organization
[:*
(when show-team-management?
[:hr {:class (stl/css :options-separator)}])
[:> dropdown-menu-item* {:on-click on-leave-organization-clicked
:class (stl/css :options-item)}
(tr "dashboard.leave-organization")]])]))
(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."
[{: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)
(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})))))]
[:> dropdown-menu* {:show true
:on-close on-close
:class (stl/css :organization-context-menu)
:style {:position "fixed"
:top (str y "px")
:left (str x "px")}}
[:> dropdown-menu-item* {:on-click on-leave-clicked
:class (stl/css :context-menu-item)}
(tr "dashboard.leave-organization")]]))

View File

@ -0,0 +1,64 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/typography.scss" as t;
@use "ds/colors.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/z-index.scss" as *;
.organization-context-menu {
position: fixed;
z-index: var(--z-index-dropdown);
min-width: $sz-160;
padding: var(--sp-xs);
background-color: var(--menu-background-color);
border: $b-2 solid var(--panel-border-color);
border-radius: $br-8;
box-shadow: 0 0 var(--sp-m) 0 var(--menu-shadow-color);
}
.context-menu-item {
@include t.use-typography("body-small");
display: flex;
align-items: center;
height: $sz-32;
padding: 0 var(--sp-m);
border-radius: $br-8;
cursor: pointer;
color: var(--menu-foreground-color-hover);
&:hover {
background-color: var(--menu-background-color-hover);
}
}
.options-item {
@include t.use-typography("body-small");
display: flex;
align-items: center;
height: $sz-40;
padding: 0 var(--sp-m);
border-radius: $br-8;
cursor: pointer;
color: var(--menu-foreground-color-hover);
&:hover {
background-color: var(--menu-background-color-hover);
}
}
.options-item.warning {
color: var(--color-negative);
}
.options-separator {
border-block-start: $b-1 solid var(--color-background-quaternary);
margin-block: var(--sp-xxs);
}

View File

@ -41,15 +41,14 @@
(mf/defc header*
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [can-edit layout on-change]}]
(let [on-click
(mf/use-fn
#(st/emit! (dd/create-project)))]
[{:keys [can-edit layout on-change ^boolean default-team?]}]
(let [on-click (mf/use-fn #(st/emit! (dd/create-project)))]
[:header {:class (stl/css :dashboard-header)
:data-testid "dashboard-header"}
[:div#dashboard-projects-title {:class (stl/css :dashboard-title)}
[:h1 (tr "dashboard.projects-title")]]
[:h1 (if default-team?
(tr "dashboard.personal-projects")
(tr "dashboard.projects-title"))]]
[:div {:class (stl/css :dashboard-header-actions)}
[:> layout-toggle* {:layout layout
:on-change on-change}]
@ -376,7 +375,8 @@
[:*
[:> header* {:can-edit can-edit
:layout layout
:on-change on-layout-change}]
:on-change on-layout-change
:default-team? default-team?}]
[:div {:class (stl/css :projects-container)}
[:*
(when (and show-team-hero?

View File

@ -9,14 +9,12 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :as da]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dtm]
[app.main.refs :as refs]
@ -25,24 +23,20 @@
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.components.link :refer [link*]]
[app.main.ui.components.organization-avatar :refer [organization-avatar*]]
[app.main.ui.dashboard.check-updates :as dcu]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.organization-team-switch :refer [organization-team-switch*]]
[app.main.ui.dashboard.project-menu :refer [project-menu-items*]]
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type
menu-team-icon*
nitrate-current-plan*
nitrate-sidebar*
show-subscription-dashboard-banner?
subscription-sidebar*]]
[app.main.ui.dashboard.team-form]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.main.ui.ds.layout.menu :refer [context-menu*]]
[app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]]
[app.main.ui.hooks :refer [use-focus-timer-ref]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.nitrate.nitrate-form]
[app.util.dom :as dom]
@ -50,7 +44,6 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.functions :as f]
[rumext.v2 :as mf]))
@ -61,42 +54,12 @@
(def ^:private search-icon
(deprecated-icon/icon-xref :search (stl/css :search-icon)))
(def ^:private tick-icon
(deprecated-icon/icon-xref :tick (stl/css :tick-icon)))
(def ^:private logo-icon
(deprecated-icon/icon-xref :logo (stl/css :logo-icon)))
(def ^:private add-icon
(deprecated-icon/icon-xref :add (stl/css :add-icon)))
(def ^:private arrow-icon
(deprecated-icon/icon-xref :arrow (stl/css :arrow-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(def ^:private organization-menu-icon
(deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon)))
(def ^:private organization-menu-icon-open
(deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon-open)))
(def ^:private pin-icon
(deprecated-icon/icon-xref :pin (stl/css :pin-icon)))
(def ^:private exit-icon
(deprecated-icon/icon-xref :exit (stl/css :exit-icon)))
(def ^:private add-organization-icon
(deprecated-icon/icon-xref :add (stl/css :add-organization-icon)))
(def ^:private arrow-up-right-icon
(deprecated-icon/icon-xref :arrow-up-right (stl/css :arrow-up-right-icon)))
(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon")
(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle")
(defn schedule-focus-by-id!
[ref element-id]
(when-let [h (mf/ref-val ref)]
@ -285,726 +248,6 @@
:on-click on-clear-click}
search-icon])]))
(mf/defc organizations-selector-dropdown*
{::mf/private true}
[{:keys [organization organizations profile] :rest props}]
(let [teams (mf/deref refs/teams)
subscription-type (get-subscription-type (-> profile :props :subscription))
team-count (count teams)
account-age-days (dnt/account-age-days profile)
on-organization-click
(mf/use-fn
(fn [event]
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
on-create-organization-click
(mf/use-fn
(mf/deps account-age-days profile subscription-type team-count)
(fn []
(if (and (not= subscription-type "unlimited")
(dnt/is-valid-license? profile))
(dnt/go-to-nitrate-ac-create-organization
"dashboard:organization-switcher")
(st/emit!
(ev/event
(cond-> {::ev/name "open-subscription-modal"
::ev/origin "dashboard:organization-switcher"
:product "nitrate:enterprise"
:source "frontend"
:has-teams (pos? team-count)
:team-count team-count}
(some? account-age-days)
(assoc :account-age-days account-age-days)))
(dnt/show-nitrate-popup
:nitrate-form
(cond-> {:subscription-start-origin "dashboard:organization-switcher"}
(= subscription-type "unlimited")
(assoc :show-contact-sales-option true)))))))
on-go-to-cc-click
(mf/use-fn
(mf/deps organization profile)
(fn []
;; Navigate to active organization if user owns it, otherwise to last visited organization
(if (and (:id organization)
(= (:id profile) (:owner-id organization)))
(dnt/go-to-nitrate-ac {:organization-id (:id organization)
:organization-slug (:slug organization)})
(dnt/go-to-nitrate-ac))))
empty-organization (d/seek #(nil? (:id %)) organizations)
default-team-id (or (:default-team-id empty-organization)
(:default-team-id profile))
organizations (filter :id organizations)
is-valid-license? (dnt/is-valid-license? profile)]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click on-organization-click
:data-value default-team-id
:class (stl/css :organization-dropdown-item)}
[:span {:class (stl/css :my-teams-icon)}
[:> raw-svg* {:id penpot-logo-icon-subtle}]]
[:span {:class (stl/css :team-text)
:title (tr "dashboard.other-teams")}
(tr "dashboard.other-teams")]
(when (= default-team-id (:default-team-id organization))
tick-icon)]
(when (seq organizations)
[:*
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:li {:role "presentation" :class (stl/css :organization-section-label)}
(tr "dashboard.section.organizations")]])
(for [organization-item organizations]
[:> dropdown-menu-item* {:on-click on-organization-click
:data-value (:default-team-id organization-item)
:class (stl/css :organization-dropdown-item)
:key (str (:default-team-id organization-item))}
[:> organization-avatar* {:organization organization-item :size "xxl"}]
[:span {:class (stl/css :team-text)
:title (:name organization-item)} (:name organization-item)]
(when (= (:default-team-id organization-item) (:default-team-id organization))
tick-icon)])
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-organization-click
:class (stl/css :organization-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-organization-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-organization")]]
(when is-valid-license?
[:> dropdown-menu-item* {:on-click on-go-to-cc-click
:class (stl/css :organization-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.go-to-admin-console")]])]))
(mf/defc teams-selector-dropdown*
{::mf/private true}
[{:keys [team profile teams] :rest props}]
(let [default-team-id (or (->> teams
vals
(filter :is-default)
first
:id)
(:default-team-id profile))
teams (dissoc teams default-team-id)
on-create-team-click
(mf/use-fn
(mf/deps team)
(fn []
(if (contains? cf/flags :admin-console)
(st/emit! (dtm/check-and-create-team (:id team)))
(st/emit! (modal/show :team-form {})))))
on-team-click
(mf/use-fn
(fn [event]
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click on-team-click
:data-value default-team-id
:class (stl/css-case :team-dropdown-item true
:team-dropdown-item-no-logo (contains? cf/flags :admin-console))}
(when-not (contains? cf/flags :admin-console)
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon])
[:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]
(when (= default-team-id (:id team))
tick-icon)]
(when (and (contains? cf/flags :admin-console)
(seq (remove :is-default (vals teams))))
[:*
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:li {:role "presentation" :class (stl/css :organization-section-label)}
(tr "dashboard.section.teams")]])
(for [team-item (remove :is-default (vals teams))]
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:id team-item)
:class (stl/css :team-dropdown-item)
:key (str (:id team-item))}
[:img {:src (cf/resolve-team-photo-url team-item)
:class (stl/css :team-picture)
:alt (:name team-item)}]
(if (and (contains? cf/flags :subscriptions)
(#{"unlimited" "enterprise"} (get-subscription-type (:subscription team-item))))
[:div {:class (stl/css :team-text-with-icon)}
[:span {:class (stl/css :team-text) :title (:name team-item)} (:name team-item)]
[:> menu-team-icon* {:subscription-type (get-subscription-type (:subscription team-item))}]]
[:span {:class (stl/css :team-text)
:title (:name team-item)} (:name team-item)])
(when (= (:id team-item) (:id team))
tick-icon)])
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-team-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
(mf/defc team-options-dropdown*
{::mf/private true}
[{:keys [team profile] :rest props}]
(let [go-members #(st/emit! (dcm/go-to-dashboard-members))
go-invitations #(st/emit! (dcm/go-to-dashboard-invitations))
go-webhooks #(st/emit! (dcm/go-to-dashboard-webhooks))
go-settings #(st/emit! (dcm/go-to-dashboard-settings))
members (get team :members)
permissions (get team :permissions)
can-rename? (or (:is-owner permissions)
(:is-admin permissions))
on-success
(fn []
;; FIXME: this should be handled in the event, not here
(let [team-id (:default-team-id profile)]
(rx/of (dcm/go-to-dashboard-recent :team-id team-id)
(modal/hide))))
on-error
(fn [error]
(let [code (-> error ex-data :code)]
(condp = code
:only-owner-can-delete-team
(rx/of (ntf/error (tr "errors.team-leave.only-owner-can-delete")))
:no-enough-members-for-leave
(rx/of (ntf/error (tr "errors.team-leave.insufficient-members")))
:member-does-not-exist
(rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists")))
:owner-cant-leave-team
(rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave")))
(rx/throw error))))
leave-fn
(mf/use-fn
(mf/deps on-success on-error)
(fn [member-id]
(let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))]
(st/emit! (dtm/leave-current-team (with-meta params
{:on-success on-success
:on-error on-error}))))))
delete-fn
(mf/use-fn
(mf/deps team on-success on-error)
(fn []
(st/emit! (dtm/delete-team (with-meta team {:on-success on-success
:on-error on-error})))))
on-rename-clicked
(mf/use-fn
(mf/deps team)
(fn []
(st/emit! (modal/show :team-form {:team team}))))
on-leave-clicked
(mf/use-fn
(mf/deps leave-fn)
#(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-confirm.message")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept leave-fn})))
on-leave-as-owner-clicked
(mf/use-fn
(mf/deps team profile leave-fn)
(fn []
(st/emit! (dtm/fetch-members)
(modal/show
{:type :leave-and-reassign
:profile profile
:team team
:accept leave-fn}))))
leave-and-close
(mf/use-fn
(mf/deps team delete-fn)
#(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-and-close-confirm.message" (:name team))
:scd-message (tr "modals.leave-and-close-confirm.hint")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept delete-fn})))
on-delete-clicked
(mf/use-fn
(mf/deps team delete-fn)
(fn []
;; Fetch fresh team data to check current permission, then show appropriate modal
(st/emit! (dtm/check-and-delete-team {:team-id (:id team)
:delete-fn delete-fn}))))]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click go-members
:class (stl/css :team-options-item)
:data-testid "team-members"}
(tr "labels.members")]
[:> dropdown-menu-item* {:on-click go-invitations
:class (stl/css :team-options-item)
:data-testid "team-invitations"}
(tr "labels.invitations")]
(when (contains? cf/flags :webhooks)
[:> dropdown-menu-item* {:on-click go-webhooks
:class (stl/css :team-options-item)}
(tr "labels.webhooks")])
[:> dropdown-menu-item* {:on-click go-settings
:class (stl/css :team-options-item)
:data-testid "team-settings"}
(tr "labels.settings")]
[:hr {:class (stl/css :team-option-separator)}]
(when can-rename?
[:> dropdown-menu-item* {:on-click on-rename-clicked
:class (stl/css :team-options-item)
:data-testid "rename-team"}
(tr "labels.rename")])
(cond
(= (count members) 1)
[:> dropdown-menu-item* {:on-click leave-and-close
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")]
(get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-leave-as-owner-clicked
:class (stl/css :team-options-item)
:data-testid "leave-team"}
(tr "dashboard.leave-team")]
(> (count members) 1)
[:> dropdown-menu-item* {:on-click on-leave-clicked
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")])
(when (get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-delete-clicked
:class (stl/css :team-options-item :warning)
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(mf/defc organization-options-dropdown*
{::mf/private true}
[{:keys [organization profile teams] :rest props}]
(let [default-team-id (mf/with-memo [teams]
(->> teams
(filter :is-default)
first
:id))
non-default-teams (mf/with-memo [teams]
(remove :is-default teams))
owned-teams (mf/with-memo [non-default-teams]
(filter #(dm/get-in % [:permissions :is-owner]) non-default-teams))
not-owned-teams (mf/with-memo [non-default-teams]
(remove #(dm/get-in % [:permissions :is-owner]) non-default-teams))
owned-teams-members-loaded?
(mf/with-memo [owned-teams]
(every? #(contains? % :members) owned-teams))
teams-to-transfer (mf/with-memo [owned-teams]
(filter #(> (count (:members %)) 1) owned-teams))
on-error
(mf/use-fn
(fn [error]
(let [code (-> error ex-data :code)
;; Map error codes to their translation keys
error-map {:not-valid-teams "errors.organization-leave.no-valid-teams"
:organization-owner-cannot-leave "errors.organization-leave.organization-owner-cannot-leave"
:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete"
:no-enough-members-for-leave "errors.team-leave.insufficient-members"
:member-does-not-exist "errors.team-leave.member-does-not-exists"
:owner-cant-leave-team "errors.team-leave.owner-cant-leave"}]
(if-let [tr-key (get error-map code)]
(rx/of (dtm/fetch-teams)
(modal/hide)
(ntf/error (tr tr-key)))
(rx/throw error)))))
leave-fn
(mf/use-fn
(mf/deps on-error organization default-team-id not-owned-teams owned-teams)
(fn [{:keys [teams-to-transfer
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))]
(st/emit! (dnt/leave-organization {:id (:id organization)
:name (:name organization)
:default-team-id default-team-id
:teams-to-delete teams-to-delete
:teams-to-leave teams-to-leave
:member-added-at member-added-at
:organization-member-count-before
organization-member-count-before
:on-error on-error})))))
on-leave-clicked
(mf/use-fn
(mf/deps leave-fn profile organization default-team-id teams-to-transfer on-error owned-teams-members-loaded?)
(fn []
(when owned-teams-members-loaded?
(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 on-error})))))]
(mf/use-effect
(mf/deps owned-teams)
(fn []
;; Fetch members for any owned team that doesn't have them yet.
(doseq [team owned-teams
:when (not (contains? team :members))]
(st/emit! (dtm/fetch-members (:id team))))))
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click on-leave-clicked
:class (stl/css :team-options-item)}
(tr "dashboard.leave-organization")]]))
(mf/defc sidebar-organization-switch*
[{:keys [team profile]}]
(let [teams (mf/deref refs/teams)
subscription-type (get-subscription-type (-> profile :props :subscription))
team-count (count teams)
account-age-days (dnt/account-age-days profile)
current-organization (dtm/team->organization team)
;; Find the "personal-projects" teams, and transform them in organizations. When
;; the selected team is directly accessible but not listed in
;; membership teams, include only its organization so the organization selector can
;; show the current selection without leaking the team into the
;; teams dropdown.
organizations (mf/with-memo [teams current-organization]
(cond-> (->> teams
vals
(filter :is-default)
(map dtm/team->organization)
(d/index-by :id))
(:id current-organization)
(assoc (:id current-organization) current-organization)))
show-dropdown? (or (dnt/is-valid-license? profile)
(> (count organizations) 1))
organization-teams (mf/with-memo [teams current-organization]
(->> teams
vals
(filter #(= (dm/get-in % [:organization :id]) (:id current-organization)))))
default-organization? (nil? (:id current-organization))
show-options? (and (not default-organization?)
(not= (:id profile) (:owner-id current-organization)))
show-organizations-menu*
(mf/use-state false)
show-organizations-menu?
(deref show-organizations-menu*)
organizations-rect*
(mf/use-state nil)
organizations-portal-container
(hooks/use-portal-container :popup)
show-organization-options-menu*
(mf/use-state false)
show-organization-options-menu?
(deref show-organization-options-menu*)
on-show-options-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-organization-options-menu* not)))
close-organization-options-menu
(mf/use-fn #(reset! show-organization-options-menu* false))
on-show-organizations-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! organizations-rect* (dom/get-bounding-rect (dom/get-current-target event)))
(swap! show-organizations-menu* not)))
on-show-organizations-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click)))))
close-organizations-menu
(mf/use-fn #(reset! show-organizations-menu* false))
on-create-organization-click
(mf/use-fn
(mf/deps account-age-days profile subscription-type team-count)
(fn []
(if (and (not= subscription-type "unlimited")
(dnt/is-valid-license? profile))
(dnt/go-to-nitrate-ac-create-organization
"dashboard:organization-switcher")
(st/emit!
(ev/event
(cond-> {::ev/name "open-subscription-modal"
::ev/origin "dashboard:create-organization-button"
:product "nitrate:enterprise"
:source "frontend"
:has-teams (pos? team-count)
:team-count team-count}
(some? account-age-days)
(assoc :account-age-days account-age-days)))
(dnt/show-nitrate-popup
:nitrate-form
(cond-> {:subscription-start-origin "dashboard:create-organization-button"}
(= subscription-type "unlimited")
(assoc :show-contact-sales-option true)))))))]
(if show-dropdown?
[:div {:class (stl/css :sidebar-organization-switch)}
[:div {:class (stl/css :organization-switch-content)}
[:button {:class (stl/css-case :current-organization true :current-organization-no-options (not show-options?))
:on-click on-show-organizations-click
:on-key-down on-show-organizations-keydown
:aria-expanded show-organizations-menu?
:aria-haspopup "menu"}
[:div {:class (stl/css :team-name)}
(if default-organization?
[:*
[:span {:class (stl/css :my-teams-icon-xxxl)}
[:> raw-svg* {:id penpot-logo-icon-subtle}]]
[:span {:class (stl/css :team-text)}
(tr "dashboard.other-teams")]]
[:*
[:> organization-avatar* {:organization current-organization :size "xxxl"}]
[:span {:class (stl/css :team-text)}
(:name current-organization)]])]
arrow-icon]
(when show-options?
[:> button* {:variant "ghost"
:type "button"
:class (stl/css :organization-options-btn)
:on-click on-show-options-click}
(if show-organization-options-menu? organization-menu-icon-open organization-menu-icon)])]
;; Organizations dropdown
(when show-organizations-menu?
(let [rect (deref organizations-rect*)]
(mf/portal
(mf/html
[:div {:style {:position "fixed"
:top (dm/str (:top rect) "px")
:left (dm/str (:left rect) "px")
:width (dm/str (:width rect) "px")}}
[:> organizations-selector-dropdown* {:show true
:on-close close-organizations-menu
:id "organizations-list"
:class (stl/css :dropdown :teams-dropdown)
:organization current-organization
:profile profile
:organizations (->> (vals organizations)
(sort-by (juxt (fn [o] (str/lower (:name o "")))
:id)))}]])
organizations-portal-container)))
;; Organization options
[:> organization-options-dropdown* {:show show-organization-options-menu?
:on-close close-organization-options-menu
:id "team-options"
:class (stl/css :dropdown :options-dropdown)
:organization current-organization
:profile profile
:teams organization-teams}]]
[:div {:class (stl/css :selected-organization)}
[:span {:class (stl/css :organization-penpot-icon)}
[:> raw-svg* {:id penpot-logo-icon}]]
"Penpot"
[:> button* {:variant "ghost"
:type "button"
:class (stl/css :create-organization)
:on-click on-create-organization-click} (tr "dashboard.plus-create-new-organization")]])))
(mf/defc sidebar-team-switch*
[{:keys [team profile]}]
(let [nitrate? (contains? cf/flags :admin-console)
organization (:organization team)
organization-id (when nitrate? (:id organization))
teams (cond->> (mf/deref refs/teams)
nitrate?
(filter #(= (dm/get-in (val %) [:organization :id]) organization-id))
nitrate?
(into {}))
subscription
(get team :subscription)
subscription-type
(get-subscription-type subscription)
show-team-options-menu*
(mf/use-state false)
show-team-options-menu?
(deref show-team-options-menu*)
show-teams-menu*
(mf/use-state false)
show-teams-menu?
(deref show-teams-menu*)
is-default?
(:is-default team)
on-show-teams-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-menu* not)))
on-show-teams-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click)))))
close-team-options-menu
(mf/use-fn #(reset! show-team-options-menu* false))
on-show-options-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-team-options-menu* not)))
on-show-options-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click)))))
close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))]
[:div {:class (stl/css :sidebar-team-switch)}
[:div {:class (stl/css :switch-content)}
[:button {:class (stl/css :current-team)
:on-click on-show-teams-click
:on-key-down on-show-teams-keydown
:aria-expanded show-teams-menu?
:aria-haspopup "menu"}
(cond
is-default?
[:div {:class (stl/css-case :team-name true
:team-name-no-logo nitrate?)}
(when-not nitrate?
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon])
[:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]]
(and (contains? cf/flags :subscriptions)
(not is-default?)
(or (= "unlimited" subscription-type) (= "enterprise" subscription-type)))
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:div {:class (stl/css :team-text-with-icon)}
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]
[:> menu-team-icon* {:subscription-type subscription-type}]]]
(and (not is-default?)
(or (not= "unlimited" subscription-type) (not= "enterprise" subscription-type)))
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]])
arrow-icon]
(when-not is-default?
[:button {:class (stl/css :switch-options)
:on-click on-show-options-click
:aria-label "team-management"
:tab-index "0"
:on-key-down on-show-options-keydown}
menu-icon])]
;; Teams Dropdown
[:> teams-selector-dropdown* {:show show-teams-menu?
:on-close close-teams-menu
:id "team-list"
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams}]
[:> team-options-dropdown* {:show show-team-options-menu?
:on-close close-team-options-menu
:id "team-options"
:class (stl/css :dropdown :options-dropdown)
:team team
:profile profile}]]))
(mf/defc sidebar-content*
{::mf/private true}
[{:keys [projects profile section team project search-term default-project] :as props}]
@ -1093,13 +336,10 @@
(reset! overflow* (> scroll-height client-height))))
[:*
[:> organization-team-switch* {:team team :profile profile}]
[:div {:class (stl/css :sidebar-content-wrapper)}
(when nitrate?
[:div {:class (stl/css :organizations-container)}
[:> sidebar-organization-switch* {:team team :profile profile}]])
[:div {:ref container
:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term
:team-id (:id team)}]

View File

@ -19,10 +19,9 @@
display: grid;
grid-row: 1 / span 2;
grid-column: 1 / span 2;
grid-template-rows: 1fr auto;
grid-template-rows: auto 1fr auto;
height: 100%;
width: 100%;
padding: var(--sp-l) 0 0 0;
margin: 0 var(--sp-l) 0 0;
border-right: $b-1 solid var(--panel-border-color);
background-color: var(--panel-background-color);
@ -69,214 +68,6 @@
color: var(--color-foreground-secondary);
}
// SIDEBAR TEAM SWITCH
.sidebar-team-switch {
position: relative;
margin: var(--sp-xs) var(--sp-l);
}
.switch-content {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: $sz-48;
width: 100%;
border-radius: $br-8;
border: $b-1 solid var(--menu-background-color);
background-color: var(--menu-background-color);
}
.current-team {
@include deprecated.button-style;
display: grid;
align-items: center;
grid-template-columns: 1fr auto;
gap: var(--sp-s);
height: 100%;
padding: 0 var(--sp-m) 0 var(--sp-l);
}
.team-name {
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
gap: var(--sp-s);
height: $sz-40;
}
.team-name-no-logo {
grid-template-columns: 1fr;
}
.team-text {
@include deprecated.text-ellipsis;
@include t.use-typography("title-small");
width: auto;
text-align: left;
color: var(--menu-foreground-color-hover);
}
.team-text-with-icon {
display: flex;
gap: var(--sp-s);
max-width: 100%;
overflow: hidden;
}
// This icon still use the old svg
.penpot-icon {
@include deprecated.flex-center;
svg {
fill: var(--icon-foreground);
width: var(--sp-xxl);
height: var(--sp-xxl);
}
}
.team-picture {
@include deprecated.flex-center;
border-radius: 50%;
height: var(--sp-xxl);
width: var(--sp-xxl);
}
.arrow-icon {
@extend %button-icon;
transform: rotate(90deg);
stroke: var(--icon-foreground);
}
.switch-options {
@include deprecated.button-style;
@include deprecated.flex-center;
max-width: var(--sp-xxl);
min-width: deprecated.$s-28;
height: 100%;
border-left: $b-1 solid var(--panel-background-color);
background-color: transparent;
}
.menu-icon {
@extend %button-icon;
stroke: var(--icon-foreground);
}
// DROPDOWNS
.teams-dropdown {
@extend %menu-dropdown;
left: 0;
top: deprecated.$s-52;
height: fit-content;
max-height: $sz-480;
min-width: deprecated.$s-248;
width: 100%;
overflow: hidden auto;
}
.team-dropdown-item {
@extend %menu-item-base;
display: grid;
grid-template-columns: var(--sp-xxl) 1fr auto;
gap: var(--sp-s);
height: $sz-40;
padding-inline-start: var(--sp-m);
}
.team-dropdown-item-no-logo {
grid-template-columns: 1fr auto;
}
.organization-dropdown-item {
@extend %menu-item-base;
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--sp-s);
height: $sz-40;
padding-inline-start: var(--sp-m);
}
.action {
--sidebar-action-icon-color: var(--icon-foreground);
--sidebar-icon-backgroun-color: var(--color-background-secondary);
&:hover {
--sidebar-action-icon-color: var(--color-background-secondary);
--sidebar-icon-backgroun-color: var(--color-accent-primary);
}
}
.icon-wrapper {
@include deprecated.flex-center;
width: var(--sp-xxl);
height: var(--sp-xxl);
border-radius: 50%;
background-color: var(--sidebar-icon-backgroun-color);
}
.add-icon {
@extend %button-icon;
width: var(--sp-xxl);
height: var(--sp-xxl);
stroke: var(--sidebar-action-icon-color);
}
.team-separator {
border-top: $b-1 solid var(--color-background-quaternary);
margin: 0;
}
.organization-section-label {
@include t.use-typography("headline-small");
color: var(--color-foreground-secondary);
list-style: none;
padding: var(--sp-m) $sz-6 $sz-6 var(--sp-m);
text-transform: uppercase;
}
.tick-icon {
@extend %button-icon-small;
stroke: var(--icon-foreground);
}
.options-dropdown {
@extend %menu-dropdown;
right: var(--sp-xxs);
top: deprecated.$s-52;
max-height: $sz-480;
&:not(.teams-dropdown) {
min-width: var(--sp-l) 0;
}
}
.team-options-item {
@extend %menu-item-base;
height: $sz-40;
}
.team-option-separator {
height: deprecated.$s-1;
margin: 0;
border-top: $b-1 solid var(--dropdown-separator-color);
}
// Sections
.sidebar-nav {
margin: 0;
@ -579,22 +370,6 @@
stroke: var(--icon-foreground);
}
.add-organization-icon {
@extend %button-icon;
width: var(--sp-l);
height: var(--sp-l);
stroke: var(--sidebar-action-icon-color);
}
.arrow-up-right-icon {
@extend %button-icon;
width: var(--sp-m);
height: var(--sp-m);
stroke: var(--sidebar-action-icon-color);
}
.upgrade-plan-section {
@include deprecated.button-style;
@ -625,156 +400,3 @@
color: var(--color-accent-tertiary);
}
.organizations-container {
align-items: center;
display: flex;
height: calc(2 * var(--sp-xxxl));
max-height: calc(2 * var(--sp-xxxl));
justify-content: space-between;
padding: 0 var(--sp-l);
}
.selected-organization {
@include t.use-typography("body-medium");
color: var(--color-foreground-primary);
width: 100%;
padding-inline-start: var(--sp-s);
display: flex;
align-items: center;
gap: var(--sp-s);
}
.create-organization {
margin-inline-start: auto;
text-transform: uppercase;
}
.organization-penpot-icon {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
height: var(--sp-xxxl);
width: var(--sp-xxxl);
background-color: var(--color-foreground-primary);
svg {
fill: var(--icon-stroke-color);
width: var(--sp-xxl);
height: var(--sp-xxl);
}
}
.organization-icon {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
height: var(--sp-xxl);
width: var(--sp-xxl);
background-color: var(--color-foreground-primary);
svg {
fill: var(--icon-stroke-color);
width: var(--sp-xl);
height: var(--sp-xl);
}
}
.my-teams-icon {
height: var(--sp-xxl);
width: var(--sp-xxl);
svg {
fill: var(--icon-stroke-color);
width: var(--sp-xxl);
height: var(--sp-xxl);
}
}
.my-teams-icon-xxxl {
height: var(--sp-xxxl);
width: var(--sp-xxxl);
svg {
fill: var(--icon-stroke-color);
width: var(--sp-xxxl);
height: var(--sp-xxxl);
}
}
.sidebar-organization-switch {
position: relative;
width: 100%;
}
.current-organization {
@include deprecated.button-style;
text-transform: none;
display: grid;
align-items: center;
grid-template-columns: 1fr auto auto;
gap: var(--sp-s);
height: 100%;
padding: 0 0 0 px2rem(10);
width: 100%;
}
.current-organization-no-options {
gap: 0;
padding: 0 px2rem(8) 0 px2rem(10);
}
.current-organization .arrow-icon {
margin-inline-end: var(--sp-xs);
}
.organization-options {
display: flex;
justify-content: center;
align-items: center;
max-width: var(--sp-xxl);
min-width: $sz-28;
height: 100%;
}
.organization-switch-content {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: $sz-48;
width: 100%;
}
.organization-options-btn {
--icon-stroke: var(--icon-foreground);
display: flex;
justify-content: center;
align-items: center;
width: $sz-28;
height: 100%;
padding: 0;
border-radius: 0 $br-8 $br-8 0;
&:hover {
--icon-stroke: var(--color-accent-primary);
cursor: pointer;
}
}
.organization-menu-icon {
@extend %button-icon;
stroke: var(--icon-stroke);
}
.organization-menu-icon-open {
@extend %button-icon;
stroke: var(--color-accent-primary);
}

View File

@ -140,6 +140,7 @@
(def ^:icon-id eye-off "eye-off")
(def ^:icon-id feedback "feedback")
(def ^:icon-id fill-content "fill-content")
(def ^:icon-id files "files")
(def ^:icon-id filter "filter")
(def ^:icon-id fixed-width "fixed-width")
(def ^:icon-id fit-content "fit-content")

View File

@ -9,11 +9,16 @@
[app.common.time :as ct]
[app.common.uri :as u]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.nitrate-audit :as nitrate-audit]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dt]
[app.main.store :as st]
[app.main.ui.auth.verify-token :as verify-token]
[cljs.test :as t :include-macros true]))
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/deftest account-age-days-test
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
@ -216,6 +221,105 @@
(t/is (string? dnt/go-to-subscription-url))
(t/is (not (u/uri? dnt/go-to-subscription-url)))))
(t/deftest organization-teams-filters-by-organization-id
(let [teams {"t1" {:id "t1" :organization {:id "org-a"}}
"t2" {:id "t2" :organization {:id "org-b"}}
"t3" {:id "t3" :is-default true}
"t4" {:id "t4" :organization {:id "org-a"}}}]
(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
(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)
st/emit! (fn
([event] (swap! emitted conj event))
([event & events] (swap! emitted into (cons event events))))]
(t/testing "with no teams offered for transfer"
(leave-fn {:teams-to-transfer nil
:member-added-at "2026-07-17T00:00:00Z"
:organization-member-count-before 3})
(t/is (= [::leave-event] @emitted))
(t/is (= {:id "org-1"
:name "Acme"
:default-team-id "default"
:teams-to-delete ["solo"]
:teams-to-leave [{:id "member-1"}]
:member-added-at "2026-07-17T00:00:00Z"
:organization-member-count-before 3
:on-error :on-error-fn}
@captured)))
(t/testing "folds transferred teams into teams-to-leave, ahead of the rest"
(leave-fn {:teams-to-transfer [{:id "pair" :reassign-to "new-owner"}]
:member-added-at "2026-07-17T00:00:00Z"
:organization-member-count-before 3})
(t/is (= [{:id "pair" :reassign-to "new-owner"}
{:id "member-1"}]
(:teams-to-leave @captured)))))))
(t/deftest team-leave-on-error-matrix
(t/testing "known error code shows a translated notification"
(let [emitted (atom [])]
(->> (dnt/team-leave-on-error (ex-info "boom" {:code :owner-cant-leave-team}))
(rx/subs! #(swap! emitted conj %)))
(t/is (= 1 (count @emitted)))
(t/is (= :visible (:status (:notification (ptk/update (first @emitted) {})))))))
(t/testing "unknown error code rethrows the original error"
(let [error (ex-info "boom" {:code :something-unmapped})
rejected (atom [])]
(->> (dnt/team-leave-on-error error)
(rx/subs! (fn [_]) #(swap! rejected conj %)))
(t/is (= [error] @rejected)))))
(t/deftest org-leave-on-error-matrix
(t/testing "known error code refetches teams, hides the modal, and notifies"
(let [emitted (atom [])]
(->> (dnt/org-leave-on-error (ex-info "boom" {:code :not-valid-teams}))
(rx/subs! #(swap! emitted conj %)))
(t/is (= 3 (count @emitted)))
(t/is (some (ptk/type? ::dt/fetch-teams) @emitted))
(t/is (some (ptk/type? ::modal/hide-modal) @emitted))
(let [notification-event (first (filter (ptk/type? ::ntf/show) @emitted))]
(t/is (some? notification-event))
(t/is (= :visible (:status (:notification (ptk/update notification-event {}))))))))
(t/testing "unknown error code rethrows the original error"
(let [error (ex-info "boom" {:code :something-unmapped})
rejected (atom [])]
(->> (dnt/org-leave-on-error error)
(rx/subs! (fn [_]) #(swap! rejected conj %)))
(t/is (= [error] @rejected)))))
(t/deftest build-admin-console-billing-url-encodes-string-callback
(t/testing "billing callback query param round-trips as a real URL string"
(let [public-uri (u/uri "https://localhost:3449/")

View File

@ -93,6 +93,7 @@
[frontend-tests.ui.gradient-handlers-test]
[frontend-tests.ui.layout-container-multiple-test]
[frontend-tests.ui.measures-menu-props-test]
[frontend-tests.ui.organization-team-switch-test]
[frontend-tests.ui.routes-test]
[frontend-tests.ui.settings-password-schema-test]
[frontend-tests.ui.settings-shortcuts-test]
@ -206,6 +207,7 @@
'frontend-tests.ui.gradient-handlers-test
'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measures-menu-props-test
'frontend-tests.ui.organization-team-switch-test
'frontend-tests.ui.routes-test
'frontend-tests.render-dimensions-test
'frontend-tests.text-editor-paste-guard-test

View File

@ -0,0 +1,136 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.ui.organization-team-switch-test
(:require
[app.common.uri :as u]
[app.main.data.nitrate :as dnt]
[app.main.ui.dashboard.organization-team-switch :as dts]
[cljs.test :as t :include-macros true]))
;; Regression coverage for the two-level organization/team switcher
;; story: ordering rules, the closed control's second line, the
;; create-team destination, and the no-op team selection. All the
;; decisions live in pure fns, so these tests are data-in,
;; assertions-out, with no DOM.
(t/deftest organization-teams-sort-alphabetically-with-personal-projects-last
(let [teams [{:id "t1" :name "Design"}
{:id "t2" :name "Personal projects" :is-default true}
{:id "t3" :name "Analytics" :is-default false}
{:id "t4" :name "beta"}]]
(t/is (= ["Analytics" "beta" "Design" "Personal projects"]
(map :name (dts/sort-organization-teams teams))))))
(t/deftest all-teams-sort-personal-projects-last-even-when-alphabetically-early
;; Display-name-only ordering would put "Personal projects" between
;; "Analytics" and "Zebra"; the default team must sort last instead.
(let [display-name (fn [team] (if (:is-default team) "Personal projects" (:name team)))
teams [{:id "t1" :name "Zebra"}
{:id "t2" :name "Personal projects" :is-default true}
{:id "t3" :name "Analytics"}]]
(t/is (= ["Analytics" "Zebra" "Personal projects"]
(map :name (dts/sort-all-teams teams display-name))))))
(t/deftest organizations-sort-alphabetically-with-other-teams-last
;; "alpha"/"Beta" check case-insensitive alphabetical order, the two
;; "Beta" orgs check the stable :id tie-break, and the nil entry is
;; the "Other teams" bucket, which always closes the list.
(let [organizations [{:id "org-c" :name "Beta"}
nil
{:id "org-a" :name "alpha"}
{:id "org-b" :name "Beta"}]]
(t/is (= ["org-a" "org-b" "org-c" nil]
(map :id (dts/sort-organizations organizations))))))
(t/deftest closed-control-line-2-matrix
(t/testing "profile with organizations, destination has an organization"
(t/is (= "Org A"
(dts/closed-control-line-2 true {:id "org-a" :name "Org A"}))))
(t/testing "profile with organizations, destination has none: line absent"
(t/is (nil? (dts/closed-control-line-2 true nil))))
(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
(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/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/is (nil? (dts/create-team-target-id organizations other-teams-id))))))
(t/deftest teams-for-organization-filters-and-sorts-per-organization
(let [personal-bucket-id (dts/organization-bucket-id nil)
teams {"t1" {:id "t1" :name "Design" :organization {:id "org-a"}}
"t2" {:id "t2" :name "Analytics" :organization {:id "org-a"}}
"t3" {:id "t3" :name "Personal projects" :is-default true}
"t4" {:id "t4" :name "Other org's team" :organization {:id "org-b"}}
"t5" {:id "t5" :name "org-b default" :is-default true :organization {:id "org-b"}}
"t6" {:id "t6" :name "org-d default" :is-default true :organization {:id "org-d"}}}]
(t/testing "matching organization id: only that organization's teams, sorted"
(t/is (= ["Analytics" "Design"]
(map :name (dts/teams-for-organization teams "org-a")))))
(t/testing "personal bucket: only teams with no organization of their own"
(t/is (= ["t3"]
(map :id (dts/teams-for-organization teams personal-bucket-id)))))
(t/testing "organization with a non-default and a default team: default sorts last"
(t/is (= ["Other org's team" "org-b default"]
(map :name (dts/teams-for-organization teams "org-b")))))
(t/testing "organization with only its own default team"
(t/is (= ["org-d default"]
(map :name (dts/teams-for-organization teams "org-d")))))
(t/testing "no matching teams: empty result"
(t/is (= [] (dts/teams-for-organization teams "org-c"))))))
(t/deftest team-href-puts-the-route-in-the-query-string-not-the-fragment
(let [router #{:dashboard-recent}
team {:id "team-1"}
href (dts/team-href router team)
parsed (u/uri href)
query (u/query-string->map (:query parsed))]
(t/is (nil? (:fragment parsed)))
(t/is (= "dashboard-recent" (:screen query)))
(t/is (= "team-1" (:team-id query)))))
(t/deftest selecting-the-current-team-is-a-no-op
(t/is (nil? (dts/team-select-target "team-1" {:id "team-1"})))
(t/is (= "team-2" (dts/team-select-target "team-2" {:id "team-1"}))))
(t/deftest create-organization-in-teams-column-requires-admin-console-flag
(t/testing "flag present: fallback action offered"
(t/is (true? (dts/show-create-organization-in-teams-column? #{:admin-console}))))
(t/testing "flag absent: fallback action hidden"
(t/is (false? (dts/show-create-organization-in-teams-column? #{})))))
(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"}
profile {:id "profile-1"}]
(t/is (= (dnt/build-admin-console-href {:organization-id "org-a"
:organization-slug "org-a-slug"})
(dts/resolve-admin-console-href organization profile)))))
(t/testing "non-owner of the previewed organization: generic href"
(let [organization {:id "org-a" :slug "org-a-slug" :owner-id "profile-1"}
profile {:id "profile-2"}]
(t/is (= (dnt/build-admin-console-href)
(dts/resolve-admin-console-href organization profile)))))
(t/testing "no organization previewed: generic href"
(t/is (= (dnt/build-admin-console-href)
(dts/resolve-admin-console-href nil {:id "profile-1"})))))

View File

@ -454,7 +454,7 @@ msgstr "(copy)"
#: src/app/main/ui/dashboard/sidebar.cljs:400
msgid "dashboard.create-new-organization"
msgstr "Create org"
msgstr "Create new organization"
#: src/app/main/ui/dashboard/sidebar.cljs:477
msgid "dashboard.create-new-team"
@ -1137,10 +1137,6 @@ msgstr "Cannot parse the plugin manifest"
msgid "dashboard.plugins.try-plugin"
msgstr "Try plugin: "
#: src/app/main/ui/dashboard/sidebar.cljs:890
msgid "dashboard.plus-create-new-organization"
msgstr "+ Create org"
#: src/app/main/data/dashboard.cljs:758
msgid "dashboard.progress-notification.deleting-files"
msgstr "Deleting files…"
@ -10834,3 +10830,9 @@ msgstr "Autosaved versions will be kept for %s days."
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path"
msgid "labels.team-management"
msgstr "Team management"
msgid "dashboard.no-organizations-yet"
msgstr "You don't belong to any organization yet"

View File

@ -461,7 +461,7 @@ msgstr "(copia)"
#: src/app/main/ui/dashboard/sidebar.cljs:400
msgid "dashboard.create-new-organization"
msgstr "Crear org"
msgstr "Crear nueva organización"
#: src/app/main/ui/dashboard/sidebar.cljs:477
msgid "dashboard.create-new-team"
@ -1137,10 +1137,6 @@ msgstr "No se puede analizar el manifiest de la extensión"
msgid "dashboard.plugins.try-plugin"
msgstr "Prueba la extensión: "
#: src/app/main/ui/dashboard/sidebar.cljs:890
msgid "dashboard.plus-create-new-organization"
msgstr "+ Crear org"
#: src/app/main/data/dashboard.cljs:758
msgid "dashboard.progress-notification.deleting-files"
msgstr "Eliminando archivos…"
@ -10474,3 +10470,9 @@ msgstr "Los autoguardados duran %s días."
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Pulsar para cerrar la ruta"
msgid "labels.team-management"
msgstr "Gestión de equipos"
msgid "dashboard.no-organizations-yet"
msgstr "Todavía no perteneces a ninguna organización"