mirror of
https://github.com/penpot/penpot.git
synced 2026-09-22 20:06:16 +00:00
🎉 Add combined organization/team switcher to the dashboard (#11760)
* 🎉 Add combined organization/team switcher to the dashboard * 📎 Code review
This commit is contained in:
parent
9fa0d9c996
commit
b32a73519e
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
5
frontend/resources/images/icons/files.svg
Normal file
5
frontend/resources/images/icons/files.svg
Normal 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 |
@ -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
|
||||
|
||||
@ -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]))
|
||||
|
||||
599
frontend/src/app/main/ui/dashboard/organization_team_switch.cljs
Normal file
599
frontend/src/app/main/ui/dashboard/organization_team_switch.cljs
Normal 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}])]))
|
||||
405
frontend/src/app/main/ui/dashboard/organization_team_switch.scss
Normal file
405
frontend/src/app/main/ui/dashboard/organization_team_switch.scss
Normal 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;
|
||||
}
|
||||
@ -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")]]))
|
||||
@ -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);
|
||||
}
|
||||
@ -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?
|
||||
|
||||
@ -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)}]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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/")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"})))))
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user