mirror of
https://github.com/penpot/penpot.git
synced 2026-09-23 12:26:17 +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 dashboardPage.teamDropdown.click();
|
||||||
await expect(page.getByText("Second Team")).toBeVisible();
|
await expect(page.getByText("Second Team")).toBeVisible();
|
||||||
await page.getByText("Second Team").click();
|
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 page.getByTestId("delete-team").click();
|
||||||
|
|
||||||
await DashboardPage.mockRPC(
|
await DashboardPage.mockRPC(
|
||||||
|
|||||||
@ -227,7 +227,7 @@ test("User opens team management dropdown", async ({ page }) => {
|
|||||||
await dashboardPage.goToSecondTeamDashboard();
|
await dashboardPage.goToSecondTeamDashboard();
|
||||||
await expect(page.getByText("Team Up")).toBeVisible();
|
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(page.getByTestId("team-members")).toBeVisible();
|
||||||
await expect(dashboardPage.page).toHaveScreenshot();
|
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)
|
(cond-> (u/join public-uri "admin-console/" path)
|
||||||
(seq query-params) (assoc :query (u/map->query-string query-params))))))
|
(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]}]
|
([{:keys [organization-id organization-slug]}]
|
||||||
(if (and organization-id organization-slug)
|
(if (and organization-id organization-slug)
|
||||||
(let [path (dm/str "organization/"
|
(let [path (dm/str "organization/"
|
||||||
(u/percent-encode organization-slug)
|
(u/percent-encode organization-slug)
|
||||||
"/"
|
"/"
|
||||||
(u/percent-encode (str organization-id))
|
(u/percent-encode (str organization-id))
|
||||||
"/people/")
|
"/people/")]
|
||||||
href (build-admin-console-url path)]
|
(build-admin-console-url path))
|
||||||
(st/emit! (rt/nav-raw :href href)))
|
(build-admin-console-url ""))))
|
||||||
(st/emit! (rt/nav-raw :href (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
|
(defn go-to-nitrate-ac-create-organization
|
||||||
[event-origin]
|
[event-origin]
|
||||||
@ -164,6 +169,58 @@
|
|||||||
(contains? #{"active" "past_due" "trialing"}
|
(contains? #{"active" "past_due" "trialing"}
|
||||||
(dm/get-in profile [:subscription :status]))))
|
(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
|
(defn leave-organization
|
||||||
[{:keys [id
|
[{:keys [id
|
||||||
name
|
name
|
||||||
@ -210,6 +267,31 @@
|
|||||||
:level :success}))))
|
:level :success}))))
|
||||||
(rx/catch on-error)))))))
|
(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
|
(defn show-leave-organization-modal
|
||||||
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
|
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
|
||||||
(ptk/reify ::show-leave-organization-modal
|
(ptk/reify ::show-leave-organization-modal
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
(mf/defc internal-dropdown-menu*
|
(mf/defc internal-dropdown-menu*
|
||||||
{::mf/private true}
|
{::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")
|
(assert (fn? on-close) "missing `on-close` prop")
|
||||||
|
|
||||||
@ -109,6 +109,7 @@
|
|||||||
[:ul {:class class
|
[:ul {:class class
|
||||||
:role "menu"
|
:role "menu"
|
||||||
:ref container
|
:ref container
|
||||||
|
:style style
|
||||||
:on-pointer-enter on-pointer-enter
|
:on-pointer-enter on-pointer-enter
|
||||||
:on-pointer-leave on-pointer-leave}
|
:on-pointer-leave on-pointer-leave}
|
||||||
children]))
|
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/defc header*
|
||||||
{::mf/wrap [mf/memo]
|
{::mf/wrap [mf/memo]
|
||||||
::mf/private true}
|
::mf/private true}
|
||||||
[{:keys [can-edit layout on-change]}]
|
[{:keys [can-edit layout on-change ^boolean default-team?]}]
|
||||||
(let [on-click
|
(let [on-click (mf/use-fn #(st/emit! (dd/create-project)))]
|
||||||
(mf/use-fn
|
|
||||||
#(st/emit! (dd/create-project)))]
|
|
||||||
|
|
||||||
[:header {:class (stl/css :dashboard-header)
|
[:header {:class (stl/css :dashboard-header)
|
||||||
:data-testid "dashboard-header"}
|
:data-testid "dashboard-header"}
|
||||||
[:div#dashboard-projects-title {:class (stl/css :dashboard-title)}
|
[: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)}
|
[:div {:class (stl/css :dashboard-header-actions)}
|
||||||
[:> layout-toggle* {:layout layout
|
[:> layout-toggle* {:layout layout
|
||||||
:on-change on-change}]
|
:on-change on-change}]
|
||||||
@ -376,7 +375,8 @@
|
|||||||
[:*
|
[:*
|
||||||
[:> header* {:can-edit can-edit
|
[:> header* {:can-edit can-edit
|
||||||
:layout layout
|
:layout layout
|
||||||
:on-change on-layout-change}]
|
:on-change on-layout-change
|
||||||
|
:default-team? default-team?}]
|
||||||
[:div {:class (stl/css :projects-container)}
|
[:div {:class (stl/css :projects-container)}
|
||||||
[:*
|
[:*
|
||||||
(when (and show-team-hero?
|
(when (and show-team-hero?
|
||||||
|
|||||||
@ -9,14 +9,12 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.uuid :as uuid]
|
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.auth :as da]
|
[app.main.data.auth :as da]
|
||||||
[app.main.data.common :as dcm]
|
[app.main.data.common :as dcm]
|
||||||
[app.main.data.dashboard :as dd]
|
[app.main.data.dashboard :as dd]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
[app.main.data.nitrate :as dnt]
|
|
||||||
[app.main.data.notifications :as ntf]
|
[app.main.data.notifications :as ntf]
|
||||||
[app.main.data.team :as dtm]
|
[app.main.data.team :as dtm]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
@ -25,24 +23,20 @@
|
|||||||
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
|
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
|
||||||
dropdown-menu-item*]]
|
dropdown-menu-item*]]
|
||||||
[app.main.ui.components.link :refer [link*]]
|
[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.check-updates :as dcu]
|
||||||
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
|
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
|
||||||
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
|
[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.project-menu :refer [project-menu-items*]]
|
||||||
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
|
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
|
||||||
get-subscription-type
|
|
||||||
menu-team-icon*
|
|
||||||
nitrate-current-plan*
|
nitrate-current-plan*
|
||||||
nitrate-sidebar*
|
nitrate-sidebar*
|
||||||
show-subscription-dashboard-banner?
|
show-subscription-dashboard-banner?
|
||||||
subscription-sidebar*]]
|
subscription-sidebar*]]
|
||||||
[app.main.ui.dashboard.team-form]
|
[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.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.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.icons :as deprecated-icon]
|
||||||
[app.main.ui.nitrate.nitrate-form]
|
[app.main.ui.nitrate.nitrate-form]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
@ -50,7 +44,6 @@
|
|||||||
[app.util.i18n :as i18n :refer [tr]]
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
[app.util.timers :as ts]
|
[app.util.timers :as ts]
|
||||||
[beicon.v2.core :as rx]
|
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[goog.functions :as f]
|
[goog.functions :as f]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
@ -61,42 +54,12 @@
|
|||||||
(def ^:private search-icon
|
(def ^:private search-icon
|
||||||
(deprecated-icon/icon-xref :search (stl/css :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
|
(def ^:private pin-icon
|
||||||
(deprecated-icon/icon-xref :pin (stl/css :pin-icon)))
|
(deprecated-icon/icon-xref :pin (stl/css :pin-icon)))
|
||||||
|
|
||||||
(def ^:private exit-icon
|
(def ^:private exit-icon
|
||||||
(deprecated-icon/icon-xref :exit (stl/css :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!
|
(defn schedule-focus-by-id!
|
||||||
[ref element-id]
|
[ref element-id]
|
||||||
(when-let [h (mf/ref-val ref)]
|
(when-let [h (mf/ref-val ref)]
|
||||||
@ -285,726 +248,6 @@
|
|||||||
:on-click on-clear-click}
|
:on-click on-clear-click}
|
||||||
search-icon])]))
|
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/defc sidebar-content*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [projects profile section team project search-term default-project] :as props}]
|
[{:keys [projects profile section team project search-term default-project] :as props}]
|
||||||
@ -1093,13 +336,10 @@
|
|||||||
(reset! overflow* (> scroll-height client-height))))
|
(reset! overflow* (> scroll-height client-height))))
|
||||||
|
|
||||||
[:*
|
[:*
|
||||||
|
[:> organization-team-switch* {:team team :profile profile}]
|
||||||
[:div {:class (stl/css :sidebar-content-wrapper)}
|
[: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
|
[:div {:ref container
|
||||||
:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
|
:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
|
||||||
[:> sidebar-team-switch* {:team team :profile profile}]
|
|
||||||
|
|
||||||
[:> sidebar-search* {:search-term search-term
|
[:> sidebar-search* {:search-term search-term
|
||||||
:team-id (:id team)}]
|
:team-id (:id team)}]
|
||||||
|
|||||||
@ -19,10 +19,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-row: 1 / span 2;
|
grid-row: 1 / span 2;
|
||||||
grid-column: 1 / span 2;
|
grid-column: 1 / span 2;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--sp-l) 0 0 0;
|
|
||||||
margin: 0 var(--sp-l) 0 0;
|
margin: 0 var(--sp-l) 0 0;
|
||||||
border-right: $b-1 solid var(--panel-border-color);
|
border-right: $b-1 solid var(--panel-border-color);
|
||||||
background-color: var(--panel-background-color);
|
background-color: var(--panel-background-color);
|
||||||
@ -69,214 +68,6 @@
|
|||||||
color: var(--color-foreground-secondary);
|
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
|
// Sections
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -579,22 +370,6 @@
|
|||||||
stroke: var(--icon-foreground);
|
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 {
|
.upgrade-plan-section {
|
||||||
@include deprecated.button-style;
|
@include deprecated.button-style;
|
||||||
|
|
||||||
@ -625,156 +400,3 @@
|
|||||||
|
|
||||||
color: var(--color-accent-tertiary);
|
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 eye-off "eye-off")
|
||||||
(def ^:icon-id feedback "feedback")
|
(def ^:icon-id feedback "feedback")
|
||||||
(def ^:icon-id fill-content "fill-content")
|
(def ^:icon-id fill-content "fill-content")
|
||||||
|
(def ^:icon-id files "files")
|
||||||
(def ^:icon-id filter "filter")
|
(def ^:icon-id filter "filter")
|
||||||
(def ^:icon-id fixed-width "fixed-width")
|
(def ^:icon-id fixed-width "fixed-width")
|
||||||
(def ^:icon-id fit-content "fit-content")
|
(def ^:icon-id fit-content "fit-content")
|
||||||
|
|||||||
@ -9,11 +9,16 @@
|
|||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as u]
|
[app.common.uri :as u]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
|
[app.main.data.modal :as modal]
|
||||||
[app.main.data.nitrate :as dnt]
|
[app.main.data.nitrate :as dnt]
|
||||||
[app.main.data.nitrate-audit :as nitrate-audit]
|
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||||
|
[app.main.data.notifications :as ntf]
|
||||||
|
[app.main.data.team :as dt]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.ui.auth.verify-token :as verify-token]
|
[app.main.ui.auth.verify-token :as verify-token]
|
||||||
[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
|
(t/deftest account-age-days-test
|
||||||
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
|
(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 (string? dnt/go-to-subscription-url))
|
||||||
(t/is (not (u/uri? 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/deftest build-admin-console-billing-url-encodes-string-callback
|
||||||
(t/testing "billing callback query param round-trips as a real URL string"
|
(t/testing "billing callback query param round-trips as a real URL string"
|
||||||
(let [public-uri (u/uri "https://localhost:3449/")
|
(let [public-uri (u/uri "https://localhost:3449/")
|
||||||
|
|||||||
@ -93,6 +93,7 @@
|
|||||||
[frontend-tests.ui.gradient-handlers-test]
|
[frontend-tests.ui.gradient-handlers-test]
|
||||||
[frontend-tests.ui.layout-container-multiple-test]
|
[frontend-tests.ui.layout-container-multiple-test]
|
||||||
[frontend-tests.ui.measures-menu-props-test]
|
[frontend-tests.ui.measures-menu-props-test]
|
||||||
|
[frontend-tests.ui.organization-team-switch-test]
|
||||||
[frontend-tests.ui.routes-test]
|
[frontend-tests.ui.routes-test]
|
||||||
[frontend-tests.ui.settings-password-schema-test]
|
[frontend-tests.ui.settings-password-schema-test]
|
||||||
[frontend-tests.ui.settings-shortcuts-test]
|
[frontend-tests.ui.settings-shortcuts-test]
|
||||||
@ -206,6 +207,7 @@
|
|||||||
'frontend-tests.ui.gradient-handlers-test
|
'frontend-tests.ui.gradient-handlers-test
|
||||||
'frontend-tests.ui.layout-container-multiple-test
|
'frontend-tests.ui.layout-container-multiple-test
|
||||||
'frontend-tests.ui.measures-menu-props-test
|
'frontend-tests.ui.measures-menu-props-test
|
||||||
|
'frontend-tests.ui.organization-team-switch-test
|
||||||
'frontend-tests.ui.routes-test
|
'frontend-tests.ui.routes-test
|
||||||
'frontend-tests.render-dimensions-test
|
'frontend-tests.render-dimensions-test
|
||||||
'frontend-tests.text-editor-paste-guard-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
|
#: src/app/main/ui/dashboard/sidebar.cljs:400
|
||||||
msgid "dashboard.create-new-organization"
|
msgid "dashboard.create-new-organization"
|
||||||
msgstr "Create org"
|
msgstr "Create new organization"
|
||||||
|
|
||||||
#: src/app/main/ui/dashboard/sidebar.cljs:477
|
#: src/app/main/ui/dashboard/sidebar.cljs:477
|
||||||
msgid "dashboard.create-new-team"
|
msgid "dashboard.create-new-team"
|
||||||
@ -1137,10 +1137,6 @@ msgstr "Cannot parse the plugin manifest"
|
|||||||
msgid "dashboard.plugins.try-plugin"
|
msgid "dashboard.plugins.try-plugin"
|
||||||
msgstr "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
|
#: src/app/main/data/dashboard.cljs:758
|
||||||
msgid "dashboard.progress-notification.deleting-files"
|
msgid "dashboard.progress-notification.deleting-files"
|
||||||
msgstr "Deleting files…"
|
msgstr "Deleting files…"
|
||||||
@ -10834,3 +10830,9 @@ msgstr "Autosaved versions will be kept for %s days."
|
|||||||
#, unused
|
#, unused
|
||||||
msgid "workspace.viewport.click-to-close-path"
|
msgid "workspace.viewport.click-to-close-path"
|
||||||
msgstr "Click to close the 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
|
#: src/app/main/ui/dashboard/sidebar.cljs:400
|
||||||
msgid "dashboard.create-new-organization"
|
msgid "dashboard.create-new-organization"
|
||||||
msgstr "Crear org"
|
msgstr "Crear nueva organización"
|
||||||
|
|
||||||
#: src/app/main/ui/dashboard/sidebar.cljs:477
|
#: src/app/main/ui/dashboard/sidebar.cljs:477
|
||||||
msgid "dashboard.create-new-team"
|
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"
|
msgid "dashboard.plugins.try-plugin"
|
||||||
msgstr "Prueba la extensión: "
|
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
|
#: src/app/main/data/dashboard.cljs:758
|
||||||
msgid "dashboard.progress-notification.deleting-files"
|
msgid "dashboard.progress-notification.deleting-files"
|
||||||
msgstr "Eliminando archivos…"
|
msgstr "Eliminando archivos…"
|
||||||
@ -10474,3 +10470,9 @@ msgstr "Los autoguardados duran %s días."
|
|||||||
#, unused
|
#, unused
|
||||||
msgid "workspace.viewport.click-to-close-path"
|
msgid "workspace.viewport.click-to-close-path"
|
||||||
msgstr "Pulsar para cerrar la ruta"
|
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