diff --git a/frontend/playwright/ui/specs/dashboard.spec.js b/frontend/playwright/ui/specs/dashboard.spec.js index 1fe01a3068..044ed5226a 100644 --- a/frontend/playwright/ui/specs/dashboard.spec.js +++ b/frontend/playwright/ui/specs/dashboard.spec.js @@ -168,7 +168,7 @@ test("Bug 10141, The team does not disappear from the team list after deletion", await dashboardPage.teamDropdown.click(); await expect(page.getByText("Second Team")).toBeVisible(); await page.getByText("Second Team").click(); - await page.getByRole("button", { name: "team-management" }).click(); + await page.getByTestId("team-options-button").click(); await page.getByTestId("delete-team").click(); await DashboardPage.mockRPC( diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 7388d5534c..3b783793f6 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -227,7 +227,7 @@ test("User opens team management dropdown", async ({ page }) => { await dashboardPage.goToSecondTeamDashboard(); await expect(page.getByText("Team Up")).toBeVisible(); - await page.getByRole("button", { name: "team-management" }).click(); + await page.getByTestId("team-options-button").click(); await expect(page.getByTestId("team-members")).toBeVisible(); await expect(dashboardPage.page).toHaveScreenshot(); diff --git a/frontend/resources/images/icons/files.svg b/frontend/resources/images/icons/files.svg new file mode 100644 index 0000000000..8e9f828fde --- /dev/null +++ b/frontend/resources/images/icons/files.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 6908b39aaa..d8bbc8be2e 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -73,19 +73,24 @@ (cond-> (u/join public-uri "admin-console/" path) (seq query-params) (assoc :query (u/map->query-string query-params)))))) -(defn go-to-nitrate-ac +(defn build-admin-console-href ([] - (st/emit! (rt/nav-raw :href (build-admin-console-url "")))) + (build-admin-console-url "")) ([{:keys [organization-id organization-slug]}] (if (and organization-id organization-slug) (let [path (dm/str "organization/" (u/percent-encode organization-slug) "/" (u/percent-encode (str organization-id)) - "/people/") - href (build-admin-console-url path)] - (st/emit! (rt/nav-raw :href href))) - (st/emit! (rt/nav-raw :href (build-admin-console-url "")))))) + "/people/")] + (build-admin-console-url path)) + (build-admin-console-url "")))) + +(defn go-to-nitrate-ac + ([] + (st/emit! (rt/nav-raw :href (build-admin-console-href)))) + ([options] + (st/emit! (rt/nav-raw :href (build-admin-console-href options))))) (defn go-to-nitrate-ac-create-organization [event-origin] @@ -164,6 +169,58 @@ (contains? #{"active" "past_due" "trialing"} (dm/get-in profile [:subscription :status])))) +(defn organization-teams + "Teams belonging to `organization-id`, out of the full team map." + [teams organization-id] + (->> teams + vals + (filter #(= (dm/get-in % [:organization :id]) organization-id)))) + +(defn organization-leave-info + "Splits the teams of an organization into what is needed to leave it: + the organization's own default team id, the teams owned by the + current user (whose membership decides whether they get deleted or + offered for transfer), and the teams the user does not own (which + are simply left)." + [org-teams] + (let [non-default-teams (remove :is-default org-teams)] + {:default-team-id (->> org-teams (filter :is-default) first :id) + :owned-teams (filter #(dm/get-in % [:permissions :is-owner]) non-default-teams) + :not-owned-teams (remove #(dm/get-in % [:permissions :is-owner]) non-default-teams)})) + +(defn transferable-teams + "Owned teams with more than one member: the ones the user can offer + to transfer to another owner instead of leaving/deleting them." + [owned-teams] + (filter #(> (count (:members %)) 1) owned-teams)) + +(def ^:private team-leave-error-messages + {:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete" + :no-enough-members-for-leave "errors.team-leave.insufficient-members" + :member-does-not-exist "errors.team-leave.member-does-not-exists" + :owner-cant-leave-team "errors.team-leave.owner-cant-leave"}) + +(defn team-leave-on-error + [error] + (let [code (-> error ex-data :code)] + (if-let [tr-key (get team-leave-error-messages code)] + (rx/of (ntf/error (tr tr-key))) + (rx/throw error)))) + +(def ^:private organization-leave-error-messages + (merge team-leave-error-messages + {:not-valid-teams "errors.organization-leave.no-valid-teams" + :organization-owner-cannot-leave "errors.organization-leave.organization-owner-cannot-leave"})) + +(defn org-leave-on-error + [error] + (let [code (-> error ex-data :code)] + (if-let [tr-key (get organization-leave-error-messages code)] + (rx/of (dt/fetch-teams) + (modal/hide) + (ntf/error (tr tr-key))) + (rx/throw error)))) + (defn leave-organization [{:keys [id name @@ -210,6 +267,31 @@ :level :success})))) (rx/catch on-error))))))) +(defn leave-organization-fn + "Builds the accept callback used by `show-leave-organization-modal`: it + folds any teams the user chose to transfer into `:teams-to-leave`, + computes `:teams-to-delete` from the owned teams left with a single + member, then emits `leave-organization`." + [{:keys [organization default-team-id owned-teams not-owned-teams on-error]}] + (fn [{:keys [teams-to-transfer member-added-at organization-member-count-before]}] + (let [teams-to-leave + (cond->> not-owned-teams + :always (map #(select-keys % [:id])) + (seq teams-to-transfer) (concat teams-to-transfer)) + + teams-to-delete + (->> owned-teams + (filter #(= (count (:members %)) 1)) + (map :id))] + (st/emit! (leave-organization {:id (:id organization) + :name (:name organization) + :default-team-id default-team-id + :teams-to-delete teams-to-delete + :teams-to-leave teams-to-leave + :member-added-at member-added-at + :organization-member-count-before organization-member-count-before + :on-error on-error}))))) + (defn show-leave-organization-modal [{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}] (ptk/reify ::show-leave-organization-modal diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index a5936bae07..1c4e79d58b 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -27,7 +27,7 @@ (mf/defc internal-dropdown-menu* {::mf/private true} - [{:keys [on-close children class id on-pointer-enter on-pointer-leave]}] + [{:keys [on-close children class id style on-pointer-enter on-pointer-leave]}] (assert (fn? on-close) "missing `on-close` prop") @@ -109,6 +109,7 @@ [:ul {:class class :role "menu" :ref container + :style style :on-pointer-enter on-pointer-enter :on-pointer-leave on-pointer-leave} children])) diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs new file mode 100644 index 0000000000..2ee01b4ad9 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs @@ -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}])])) diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss new file mode 100644 index 0000000000..ecda04f741 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -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; +} diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs new file mode 100644 index 0000000000..ca28c6aa21 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs @@ -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")]])) diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.scss b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.scss new file mode 100644 index 0000000000..9a0b8b7129 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 063eb55afb..fef26f5b9b 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -41,15 +41,14 @@ (mf/defc header* {::mf/wrap [mf/memo] ::mf/private true} - [{:keys [can-edit layout on-change]}] - (let [on-click - (mf/use-fn - #(st/emit! (dd/create-project)))] - + [{:keys [can-edit layout on-change ^boolean default-team?]}] + (let [on-click (mf/use-fn #(st/emit! (dd/create-project)))] [:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"} [:div#dashboard-projects-title {:class (stl/css :dashboard-title)} - [:h1 (tr "dashboard.projects-title")]] + [:h1 (if default-team? + (tr "dashboard.personal-projects") + (tr "dashboard.projects-title"))]] [:div {:class (stl/css :dashboard-header-actions)} [:> layout-toggle* {:layout layout :on-change on-change}] @@ -376,7 +375,8 @@ [:* [:> header* {:can-edit can-edit :layout layout - :on-change on-layout-change}] + :on-change on-layout-change + :default-team? default-team?}] [:div {:class (stl/css :projects-container)} [:* (when (and show-team-hero? diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index fe683c8273..2f37187244 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -9,14 +9,12 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] [app.main.data.event :as ev] [app.main.data.modal :as modal] - [app.main.data.nitrate :as dnt] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] [app.main.refs :as refs] @@ -25,24 +23,20 @@ [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] - [app.main.ui.components.organization-avatar :refer [organization-avatar*]] [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] + [app.main.ui.dashboard.organization-team-switch :refer [organization-team-switch*]] [app.main.ui.dashboard.project-menu :refer [project-menu-items*]] [app.main.ui.dashboard.subscription :refer [dashboard-cta* - get-subscription-type - menu-team-icon* nitrate-current-plan* nitrate-sidebar* show-subscription-dashboard-banner? subscription-sidebar*]] [app.main.ui.dashboard.team-form] - [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] - [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] [app.main.ui.ds.layout.menu :refer [context-menu*]] - [app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]] + [app.main.ui.hooks :refer [use-focus-timer-ref]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.nitrate.nitrate-form] [app.util.dom :as dom] @@ -50,7 +44,6 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.timers :as ts] - [beicon.v2.core :as rx] [cuerdas.core :as str] [goog.functions :as f] [rumext.v2 :as mf])) @@ -61,42 +54,12 @@ (def ^:private search-icon (deprecated-icon/icon-xref :search (stl/css :search-icon))) -(def ^:private tick-icon - (deprecated-icon/icon-xref :tick (stl/css :tick-icon))) - -(def ^:private logo-icon - (deprecated-icon/icon-xref :logo (stl/css :logo-icon))) - -(def ^:private add-icon - (deprecated-icon/icon-xref :add (stl/css :add-icon))) - -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - -(def ^:private organization-menu-icon - (deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon))) - -(def ^:private organization-menu-icon-open - (deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon-open))) - (def ^:private pin-icon (deprecated-icon/icon-xref :pin (stl/css :pin-icon))) (def ^:private exit-icon (deprecated-icon/icon-xref :exit (stl/css :exit-icon))) -(def ^:private add-organization-icon - (deprecated-icon/icon-xref :add (stl/css :add-organization-icon))) - -(def ^:private arrow-up-right-icon - (deprecated-icon/icon-xref :arrow-up-right (stl/css :arrow-up-right-icon))) - -(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon") -(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle") - (defn schedule-focus-by-id! [ref element-id] (when-let [h (mf/ref-val ref)] @@ -285,726 +248,6 @@ :on-click on-clear-click} search-icon])])) -(mf/defc organizations-selector-dropdown* - {::mf/private true} - [{:keys [organization organizations profile] :rest props}] - (let [teams (mf/deref refs/teams) - - subscription-type (get-subscription-type (-> profile :props :subscription)) - - team-count (count teams) - - account-age-days (dnt/account-age-days profile) - - on-organization-click - (mf/use-fn - (fn [event] - (let [team-id (-> (dom/get-current-target event) - (dom/get-data "value") - (uuid/parse))] - (st/emit! (dcm/go-to-dashboard-recent :team-id team-id))))) - - on-create-organization-click - (mf/use-fn - (mf/deps account-age-days profile subscription-type team-count) - (fn [] - (if (and (not= subscription-type "unlimited") - (dnt/is-valid-license? profile)) - (dnt/go-to-nitrate-ac-create-organization - "dashboard:organization-switcher") - (st/emit! - (ev/event - (cond-> {::ev/name "open-subscription-modal" - ::ev/origin "dashboard:organization-switcher" - :product "nitrate:enterprise" - :source "frontend" - :has-teams (pos? team-count) - :team-count team-count} - (some? account-age-days) - (assoc :account-age-days account-age-days))) - (dnt/show-nitrate-popup - :nitrate-form - (cond-> {:subscription-start-origin "dashboard:organization-switcher"} - (= subscription-type "unlimited") - (assoc :show-contact-sales-option true))))))) - - on-go-to-cc-click - (mf/use-fn - (mf/deps organization profile) - (fn [] - ;; Navigate to active organization if user owns it, otherwise to last visited organization - (if (and (:id organization) - (= (:id profile) (:owner-id organization))) - (dnt/go-to-nitrate-ac {:organization-id (:id organization) - :organization-slug (:slug organization)}) - (dnt/go-to-nitrate-ac)))) - - empty-organization (d/seek #(nil? (:id %)) organizations) - default-team-id (or (:default-team-id empty-organization) - (:default-team-id profile)) - - organizations (filter :id organizations) - - is-valid-license? (dnt/is-valid-license? profile)] - - [:> dropdown-menu* props - - [:> dropdown-menu-item* {:on-click on-organization-click - :data-value default-team-id - :class (stl/css :organization-dropdown-item)} - [:span {:class (stl/css :my-teams-icon)} - [:> raw-svg* {:id penpot-logo-icon-subtle}]] - [:span {:class (stl/css :team-text) - :title (tr "dashboard.other-teams")} - (tr "dashboard.other-teams")] - (when (= default-team-id (:default-team-id organization)) - tick-icon)] - (when (seq organizations) - [:* - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:li {:role "presentation" :class (stl/css :organization-section-label)} - (tr "dashboard.section.organizations")]]) - - (for [organization-item organizations] - [:> dropdown-menu-item* {:on-click on-organization-click - :data-value (:default-team-id organization-item) - :class (stl/css :organization-dropdown-item) - :key (str (:default-team-id organization-item))} - [:> organization-avatar* {:organization organization-item :size "xxl"}] - [:span {:class (stl/css :team-text) - :title (:name organization-item)} (:name organization-item)] - (when (= (:default-team-id organization-item) (:default-team-id organization)) - tick-icon)]) - - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:> dropdown-menu-item* {:on-click on-create-organization-click - :class (stl/css :organization-dropdown-item :action)} - [:span {:class (stl/css :icon-wrapper)} add-organization-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-organization")]] - (when is-valid-license? - [:> dropdown-menu-item* {:on-click on-go-to-cc-click - :class (stl/css :organization-dropdown-item :action)} - [:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.go-to-admin-console")]])])) - -(mf/defc teams-selector-dropdown* - {::mf/private true} - [{:keys [team profile teams] :rest props}] - (let [default-team-id (or (->> teams - vals - (filter :is-default) - first - :id) - (:default-team-id profile)) - - teams (dissoc teams default-team-id) - on-create-team-click - (mf/use-fn - (mf/deps team) - (fn [] - (if (contains? cf/flags :admin-console) - (st/emit! (dtm/check-and-create-team (:id team))) - (st/emit! (modal/show :team-form {}))))) - - on-team-click - (mf/use-fn - (fn [event] - (let [team-id (-> (dom/get-current-target event) - (dom/get-data "value") - (uuid/parse))] - (st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))] - - [:> dropdown-menu* props - [:> dropdown-menu-item* {:on-click on-team-click - :data-value default-team-id - :class (stl/css-case :team-dropdown-item true - :team-dropdown-item-no-logo (contains? cf/flags :admin-console))} - (when-not (contains? cf/flags :admin-console) - [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - - [:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")] - (when (= default-team-id (:id team)) - tick-icon)] - - (when (and (contains? cf/flags :admin-console) - (seq (remove :is-default (vals teams)))) - [:* - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:li {:role "presentation" :class (stl/css :organization-section-label)} - (tr "dashboard.section.teams")]]) - - (for [team-item (remove :is-default (vals teams))] - [:> dropdown-menu-item* {:on-click on-team-click - :data-value (:id team-item) - :class (stl/css :team-dropdown-item) - :key (str (:id team-item))} - [:img {:src (cf/resolve-team-photo-url team-item) - :class (stl/css :team-picture) - :alt (:name team-item)}] - - (if (and (contains? cf/flags :subscriptions) - (#{"unlimited" "enterprise"} (get-subscription-type (:subscription team-item)))) - [:div {:class (stl/css :team-text-with-icon)} - [:span {:class (stl/css :team-text) :title (:name team-item)} (:name team-item)] - [:> menu-team-icon* {:subscription-type (get-subscription-type (:subscription team-item))}]] - [:span {:class (stl/css :team-text) - :title (:name team-item)} (:name team-item)]) - (when (= (:id team-item) (:id team)) - tick-icon)]) - - - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:> dropdown-menu-item* {:on-click on-create-team-click - :class (stl/css :team-dropdown-item :action)} - [:span {:class (stl/css :icon-wrapper)} add-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]])) - -(mf/defc team-options-dropdown* - {::mf/private true} - [{:keys [team profile] :rest props}] - (let [go-members #(st/emit! (dcm/go-to-dashboard-members)) - go-invitations #(st/emit! (dcm/go-to-dashboard-invitations)) - go-webhooks #(st/emit! (dcm/go-to-dashboard-webhooks)) - go-settings #(st/emit! (dcm/go-to-dashboard-settings)) - - members (get team :members) - permissions (get team :permissions) - can-rename? (or (:is-owner permissions) - (:is-admin permissions)) - - on-success - (fn [] - ;; FIXME: this should be handled in the event, not here - (let [team-id (:default-team-id profile)] - (rx/of (dcm/go-to-dashboard-recent :team-id team-id) - (modal/hide)))) - - on-error - (fn [error] - (let [code (-> error ex-data :code)] - (condp = code - :only-owner-can-delete-team - (rx/of (ntf/error (tr "errors.team-leave.only-owner-can-delete"))) - - :no-enough-members-for-leave - (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) - - :member-does-not-exist - (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) - - :owner-cant-leave-team - (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) - - (rx/throw error)))) - - leave-fn - (mf/use-fn - (mf/deps on-success on-error) - (fn [member-id] - (let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))] - (st/emit! (dtm/leave-current-team (with-meta params - {:on-success on-success - :on-error on-error})))))) - delete-fn - (mf/use-fn - (mf/deps team on-success on-error) - (fn [] - (st/emit! (dtm/delete-team (with-meta team {:on-success on-success - :on-error on-error}))))) - on-rename-clicked - (mf/use-fn - (mf/deps team) - (fn [] - (st/emit! (modal/show :team-form {:team team})))) - - on-leave-clicked - (mf/use-fn - (mf/deps leave-fn) - #(st/emit! (modal/show - {:type :confirm - :title (tr "modals.leave-confirm.title") - :message (tr "modals.leave-confirm.message") - :accept-label (tr "modals.leave-confirm.accept") - :on-accept leave-fn}))) - - on-leave-as-owner-clicked - (mf/use-fn - (mf/deps team profile leave-fn) - (fn [] - (st/emit! (dtm/fetch-members) - (modal/show - {:type :leave-and-reassign - :profile profile - :team team - :accept leave-fn})))) - - leave-and-close - (mf/use-fn - (mf/deps team delete-fn) - #(st/emit! (modal/show - {:type :confirm - :title (tr "modals.leave-confirm.title") - :message (tr "modals.leave-and-close-confirm.message" (:name team)) - :scd-message (tr "modals.leave-and-close-confirm.hint") - :accept-label (tr "modals.leave-confirm.accept") - :on-accept delete-fn}))) - - on-delete-clicked - (mf/use-fn - (mf/deps team delete-fn) - (fn [] - ;; Fetch fresh team data to check current permission, then show appropriate modal - (st/emit! (dtm/check-and-delete-team {:team-id (:id team) - :delete-fn delete-fn}))))] - [:> dropdown-menu* props - - [:> dropdown-menu-item* {:on-click go-members - :class (stl/css :team-options-item) - :data-testid "team-members"} - (tr "labels.members")] - [:> dropdown-menu-item* {:on-click go-invitations - :class (stl/css :team-options-item) - :data-testid "team-invitations"} - (tr "labels.invitations")] - - (when (contains? cf/flags :webhooks) - [:> dropdown-menu-item* {:on-click go-webhooks - :class (stl/css :team-options-item)} - (tr "labels.webhooks")]) - - [:> dropdown-menu-item* {:on-click go-settings - :class (stl/css :team-options-item) - :data-testid "team-settings"} - (tr "labels.settings")] - - [:hr {:class (stl/css :team-option-separator)}] - (when can-rename? - [:> dropdown-menu-item* {:on-click on-rename-clicked - :class (stl/css :team-options-item) - :data-testid "rename-team"} - (tr "labels.rename")]) - - (cond - (= (count members) 1) - [:> dropdown-menu-item* {:on-click leave-and-close - :class (stl/css :team-options-item)} - (tr "dashboard.leave-team")] - - - (get-in team [:permissions :is-owner]) - [:> dropdown-menu-item* {:on-click on-leave-as-owner-clicked - :class (stl/css :team-options-item) - :data-testid "leave-team"} - (tr "dashboard.leave-team")] - - (> (count members) 1) - [:> dropdown-menu-item* {:on-click on-leave-clicked - :class (stl/css :team-options-item)} - (tr "dashboard.leave-team")]) - - (when (get-in team [:permissions :is-owner]) - [:> dropdown-menu-item* {:on-click on-delete-clicked - :class (stl/css :team-options-item :warning) - :data-testid "delete-team"} - (tr "dashboard.delete-team")])])) - -(mf/defc organization-options-dropdown* - {::mf/private true} - [{:keys [organization profile teams] :rest props}] - (let [default-team-id (mf/with-memo [teams] - (->> teams - (filter :is-default) - first - :id)) - non-default-teams (mf/with-memo [teams] - (remove :is-default teams)) - owned-teams (mf/with-memo [non-default-teams] - (filter #(dm/get-in % [:permissions :is-owner]) non-default-teams)) - not-owned-teams (mf/with-memo [non-default-teams] - (remove #(dm/get-in % [:permissions :is-owner]) non-default-teams)) - owned-teams-members-loaded? - (mf/with-memo [owned-teams] - (every? #(contains? % :members) owned-teams)) - teams-to-transfer (mf/with-memo [owned-teams] - (filter #(> (count (:members %)) 1) owned-teams)) - - on-error - (mf/use-fn - (fn [error] - (let [code (-> error ex-data :code) - ;; Map error codes to their translation keys - error-map {:not-valid-teams "errors.organization-leave.no-valid-teams" - :organization-owner-cannot-leave "errors.organization-leave.organization-owner-cannot-leave" - :only-owner-can-delete-team "errors.team-leave.only-owner-can-delete" - :no-enough-members-for-leave "errors.team-leave.insufficient-members" - :member-does-not-exist "errors.team-leave.member-does-not-exists" - :owner-cant-leave-team "errors.team-leave.owner-cant-leave"}] - - (if-let [tr-key (get error-map code)] - (rx/of (dtm/fetch-teams) - (modal/hide) - (ntf/error (tr tr-key))) - (rx/throw error))))) - - leave-fn - (mf/use-fn - (mf/deps on-error organization default-team-id not-owned-teams owned-teams) - (fn [{:keys [teams-to-transfer - member-added-at - organization-member-count-before]}] - (let [teams-to-leave (cond->> not-owned-teams - :always - (map #(select-keys % [:id])) - (seq teams-to-transfer) - (concat teams-to-transfer)) - teams-to-delete (->> owned-teams - (filter #(= (count (:members %)) 1)) - (map :id))] - - - (st/emit! (dnt/leave-organization {:id (:id organization) - :name (:name organization) - :default-team-id default-team-id - :teams-to-delete teams-to-delete - :teams-to-leave teams-to-leave - :member-added-at member-added-at - :organization-member-count-before - organization-member-count-before - :on-error on-error}))))) - - on-leave-clicked - (mf/use-fn - (mf/deps leave-fn profile organization default-team-id teams-to-transfer on-error owned-teams-members-loaded?) - (fn [] - (when owned-teams-members-loaded? - (st/emit! (dnt/show-leave-organization-modal {:organization organization - :profile profile - :default-team-id default-team-id - :leave-fn leave-fn - :teams-to-transfer teams-to-transfer - :on-error on-error})))))] - (mf/use-effect - (mf/deps owned-teams) - (fn [] - ;; Fetch members for any owned team that doesn't have them yet. - (doseq [team owned-teams - :when (not (contains? team :members))] - (st/emit! (dtm/fetch-members (:id team)))))) - [:> dropdown-menu* props - - [:> dropdown-menu-item* {:on-click on-leave-clicked - :class (stl/css :team-options-item)} - (tr "dashboard.leave-organization")]])) - - -(mf/defc sidebar-organization-switch* - [{:keys [team profile]}] - (let [teams (mf/deref refs/teams) - - subscription-type (get-subscription-type (-> profile :props :subscription)) - - team-count (count teams) - - account-age-days (dnt/account-age-days profile) - - current-organization (dtm/team->organization team) - - ;; Find the "personal-projects" teams, and transform them in organizations. When - ;; the selected team is directly accessible but not listed in - ;; membership teams, include only its organization so the organization selector can - ;; show the current selection without leaking the team into the - ;; teams dropdown. - organizations (mf/with-memo [teams current-organization] - (cond-> (->> teams - vals - (filter :is-default) - (map dtm/team->organization) - (d/index-by :id)) - (:id current-organization) - (assoc (:id current-organization) current-organization))) - - show-dropdown? (or (dnt/is-valid-license? profile) - (> (count organizations) 1)) - - organization-teams (mf/with-memo [teams current-organization] - (->> teams - vals - (filter #(= (dm/get-in % [:organization :id]) (:id current-organization))))) - - default-organization? (nil? (:id current-organization)) - - show-options? (and (not default-organization?) - (not= (:id profile) (:owner-id current-organization))) - - show-organizations-menu* - (mf/use-state false) - - show-organizations-menu? - (deref show-organizations-menu*) - - organizations-rect* - (mf/use-state nil) - - organizations-portal-container - (hooks/use-portal-container :popup) - - show-organization-options-menu* - (mf/use-state false) - - show-organization-options-menu? - (deref show-organization-options-menu*) - - on-show-options-click - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (swap! show-organization-options-menu* not))) - - close-organization-options-menu - (mf/use-fn #(reset! show-organization-options-menu* false)) - - on-show-organizations-click - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (reset! organizations-rect* (dom/get-bounding-rect (dom/get-current-target event))) - (swap! show-organizations-menu* not))) - - on-show-organizations-keydown - (mf/use-fn - (fn [event] - (when (or (kbd/space? event) - (kbd/enter? event)) - (dom/prevent-default event) - (dom/stop-propagation event) - (some-> (dom/get-current-target event) - (dom/click))))) - close-organizations-menu - (mf/use-fn #(reset! show-organizations-menu* false)) - - on-create-organization-click - (mf/use-fn - (mf/deps account-age-days profile subscription-type team-count) - (fn [] - (if (and (not= subscription-type "unlimited") - (dnt/is-valid-license? profile)) - (dnt/go-to-nitrate-ac-create-organization - "dashboard:organization-switcher") - (st/emit! - (ev/event - (cond-> {::ev/name "open-subscription-modal" - ::ev/origin "dashboard:create-organization-button" - :product "nitrate:enterprise" - :source "frontend" - :has-teams (pos? team-count) - :team-count team-count} - (some? account-age-days) - (assoc :account-age-days account-age-days))) - (dnt/show-nitrate-popup - :nitrate-form - (cond-> {:subscription-start-origin "dashboard:create-organization-button"} - (= subscription-type "unlimited") - (assoc :show-contact-sales-option true)))))))] - (if show-dropdown? - [:div {:class (stl/css :sidebar-organization-switch)} - [:div {:class (stl/css :organization-switch-content)} - [:button {:class (stl/css-case :current-organization true :current-organization-no-options (not show-options?)) - :on-click on-show-organizations-click - :on-key-down on-show-organizations-keydown - :aria-expanded show-organizations-menu? - :aria-haspopup "menu"} - [:div {:class (stl/css :team-name)} - (if default-organization? - [:* - [:span {:class (stl/css :my-teams-icon-xxxl)} - [:> raw-svg* {:id penpot-logo-icon-subtle}]] - [:span {:class (stl/css :team-text)} - (tr "dashboard.other-teams")]] - [:* - [:> organization-avatar* {:organization current-organization :size "xxxl"}] - [:span {:class (stl/css :team-text)} - (:name current-organization)]])] - arrow-icon] - (when show-options? - [:> button* {:variant "ghost" - :type "button" - :class (stl/css :organization-options-btn) - :on-click on-show-options-click} - (if show-organization-options-menu? organization-menu-icon-open organization-menu-icon)])] - - - ;; Organizations dropdown - (when show-organizations-menu? - (let [rect (deref organizations-rect*)] - (mf/portal - (mf/html - [:div {:style {:position "fixed" - :top (dm/str (:top rect) "px") - :left (dm/str (:left rect) "px") - :width (dm/str (:width rect) "px")}} - [:> organizations-selector-dropdown* {:show true - :on-close close-organizations-menu - :id "organizations-list" - :class (stl/css :dropdown :teams-dropdown) - :organization current-organization - :profile profile - :organizations (->> (vals organizations) - (sort-by (juxt (fn [o] (str/lower (:name o ""))) - :id)))}]]) - - - - organizations-portal-container))) - ;; Organization options - [:> organization-options-dropdown* {:show show-organization-options-menu? - :on-close close-organization-options-menu - :id "team-options" - :class (stl/css :dropdown :options-dropdown) - :organization current-organization - :profile profile - :teams organization-teams}]] - [:div {:class (stl/css :selected-organization)} - [:span {:class (stl/css :organization-penpot-icon)} - [:> raw-svg* {:id penpot-logo-icon}]] - "Penpot" - [:> button* {:variant "ghost" - :type "button" - :class (stl/css :create-organization) - :on-click on-create-organization-click} (tr "dashboard.plus-create-new-organization")]]))) - -(mf/defc sidebar-team-switch* - [{:keys [team profile]}] - (let [nitrate? (contains? cf/flags :admin-console) - organization (:organization team) - organization-id (when nitrate? (:id organization)) - teams (cond->> (mf/deref refs/teams) - nitrate? - (filter #(= (dm/get-in (val %) [:organization :id]) organization-id)) - nitrate? - (into {})) - - subscription - (get team :subscription) - - subscription-type - (get-subscription-type subscription) - - show-team-options-menu* - (mf/use-state false) - - show-team-options-menu? - (deref show-team-options-menu*) - - show-teams-menu* - (mf/use-state false) - - show-teams-menu? - (deref show-teams-menu*) - - is-default? - (:is-default team) - - on-show-teams-click - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (swap! show-teams-menu* not))) - - on-show-teams-keydown - (mf/use-fn - (fn [event] - (when (or (kbd/space? event) - (kbd/enter? event)) - (dom/prevent-default event) - (dom/stop-propagation event) - (some-> (dom/get-current-target event) - (dom/click))))) - - close-team-options-menu - (mf/use-fn #(reset! show-team-options-menu* false)) - - on-show-options-click - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (swap! show-team-options-menu* not))) - - on-show-options-keydown - (mf/use-fn - (fn [event] - (when (or (kbd/space? event) - (kbd/enter? event)) - (dom/prevent-default event) - (dom/stop-propagation event) - - (some-> (dom/get-current-target event) - (dom/click))))) - - close-teams-menu - (mf/use-fn #(reset! show-teams-menu* false))] - - [:div {:class (stl/css :sidebar-team-switch)} - [:div {:class (stl/css :switch-content)} - [:button {:class (stl/css :current-team) - :on-click on-show-teams-click - :on-key-down on-show-teams-keydown - :aria-expanded show-teams-menu? - :aria-haspopup "menu"} - (cond - is-default? - [:div {:class (stl/css-case :team-name true - :team-name-no-logo nitrate?)} - (when-not nitrate? - [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]] - - (and (contains? cf/flags :subscriptions) - (not is-default?) - (or (= "unlimited" subscription-type) (= "enterprise" subscription-type))) - [:div {:class (stl/css :team-name)} - [:img {:src (cf/resolve-team-photo-url team) - :class (stl/css :team-picture) - :alt (:name team)}] - [:div {:class (stl/css :team-text-with-icon)} - [:span {:class (stl/css :team-text) :title (:name team)} (:name team)] - [:> menu-team-icon* {:subscription-type subscription-type}]]] - - - (and (not is-default?) - (or (not= "unlimited" subscription-type) (not= "enterprise" subscription-type))) - [:div {:class (stl/css :team-name)} - [:img {:src (cf/resolve-team-photo-url team) - :class (stl/css :team-picture) - :alt (:name team)}] - [:span {:class (stl/css :team-text) :title (:name team)} (:name team)]]) - - arrow-icon] - - (when-not is-default? - [:button {:class (stl/css :switch-options) - :on-click on-show-options-click - :aria-label "team-management" - :tab-index "0" - :on-key-down on-show-options-keydown} - menu-icon])] - - ;; Teams Dropdown - - [:> teams-selector-dropdown* {:show show-teams-menu? - :on-close close-teams-menu - :id "team-list" - :class (stl/css :dropdown :teams-dropdown) - :team team - :profile profile - :teams teams}] - - [:> team-options-dropdown* {:show show-team-options-menu? - :on-close close-team-options-menu - :id "team-options" - :class (stl/css :dropdown :options-dropdown) - :team team - :profile profile}]])) - (mf/defc sidebar-content* {::mf/private true} [{:keys [projects profile section team project search-term default-project] :as props}] @@ -1093,13 +336,10 @@ (reset! overflow* (> scroll-height client-height)))) [:* + [:> organization-team-switch* {:team team :profile profile}] [:div {:class (stl/css :sidebar-content-wrapper)} - (when nitrate? - [:div {:class (stl/css :organizations-container)} - [:> sidebar-organization-switch* {:team team :profile profile}]]) [:div {:ref container :class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)} - [:> sidebar-team-switch* {:team team :profile profile}] [:> sidebar-search* {:search-term search-term :team-id (:id team)}] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 826d7db7c4..4bbd6d243b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -19,10 +19,9 @@ display: grid; grid-row: 1 / span 2; grid-column: 1 / span 2; - grid-template-rows: 1fr auto; + grid-template-rows: auto 1fr auto; height: 100%; width: 100%; - padding: var(--sp-l) 0 0 0; margin: 0 var(--sp-l) 0 0; border-right: $b-1 solid var(--panel-border-color); background-color: var(--panel-background-color); @@ -69,214 +68,6 @@ color: var(--color-foreground-secondary); } -// SIDEBAR TEAM SWITCH -.sidebar-team-switch { - position: relative; - margin: var(--sp-xs) var(--sp-l); -} - -.switch-content { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - height: $sz-48; - width: 100%; - border-radius: $br-8; - border: $b-1 solid var(--menu-background-color); - background-color: var(--menu-background-color); -} - -.current-team { - @include deprecated.button-style; - - display: grid; - align-items: center; - grid-template-columns: 1fr auto; - gap: var(--sp-s); - height: 100%; - padding: 0 var(--sp-m) 0 var(--sp-l); -} - -.team-name { - display: grid; - align-items: center; - grid-template-columns: auto 1fr; - gap: var(--sp-s); - height: $sz-40; -} - -.team-name-no-logo { - grid-template-columns: 1fr; -} - -.team-text { - @include deprecated.text-ellipsis; - @include t.use-typography("title-small"); - - width: auto; - text-align: left; - color: var(--menu-foreground-color-hover); -} - -.team-text-with-icon { - display: flex; - gap: var(--sp-s); - max-width: 100%; - overflow: hidden; -} - -// This icon still use the old svg -.penpot-icon { - @include deprecated.flex-center; - - svg { - fill: var(--icon-foreground); - width: var(--sp-xxl); - height: var(--sp-xxl); - } -} - -.team-picture { - @include deprecated.flex-center; - - border-radius: 50%; - height: var(--sp-xxl); - width: var(--sp-xxl); -} - -.arrow-icon { - @extend %button-icon; - - transform: rotate(90deg); - stroke: var(--icon-foreground); -} - -.switch-options { - @include deprecated.button-style; - @include deprecated.flex-center; - - max-width: var(--sp-xxl); - min-width: deprecated.$s-28; - height: 100%; - border-left: $b-1 solid var(--panel-background-color); - background-color: transparent; -} - -.menu-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); -} - -// DROPDOWNS - -.teams-dropdown { - @extend %menu-dropdown; - - left: 0; - top: deprecated.$s-52; - height: fit-content; - max-height: $sz-480; - min-width: deprecated.$s-248; - width: 100%; - overflow: hidden auto; -} - -.team-dropdown-item { - @extend %menu-item-base; - - display: grid; - grid-template-columns: var(--sp-xxl) 1fr auto; - gap: var(--sp-s); - height: $sz-40; - padding-inline-start: var(--sp-m); -} - -.team-dropdown-item-no-logo { - grid-template-columns: 1fr auto; -} - -.organization-dropdown-item { - @extend %menu-item-base; - - display: grid; - grid-template-columns: auto 1fr auto; - gap: var(--sp-s); - height: $sz-40; - padding-inline-start: var(--sp-m); -} - -.action { - --sidebar-action-icon-color: var(--icon-foreground); - --sidebar-icon-backgroun-color: var(--color-background-secondary); - - &:hover { - --sidebar-action-icon-color: var(--color-background-secondary); - --sidebar-icon-backgroun-color: var(--color-accent-primary); - } -} - -.icon-wrapper { - @include deprecated.flex-center; - - width: var(--sp-xxl); - height: var(--sp-xxl); - border-radius: 50%; - background-color: var(--sidebar-icon-backgroun-color); -} - -.add-icon { - @extend %button-icon; - - width: var(--sp-xxl); - height: var(--sp-xxl); - stroke: var(--sidebar-action-icon-color); -} - -.team-separator { - border-top: $b-1 solid var(--color-background-quaternary); - margin: 0; -} - -.organization-section-label { - @include t.use-typography("headline-small"); - - color: var(--color-foreground-secondary); - list-style: none; - padding: var(--sp-m) $sz-6 $sz-6 var(--sp-m); - text-transform: uppercase; -} - -.tick-icon { - @extend %button-icon-small; - - stroke: var(--icon-foreground); -} - -.options-dropdown { - @extend %menu-dropdown; - - right: var(--sp-xxs); - top: deprecated.$s-52; - max-height: $sz-480; - - &:not(.teams-dropdown) { - min-width: var(--sp-l) 0; - } -} - -.team-options-item { - @extend %menu-item-base; - - height: $sz-40; -} - -.team-option-separator { - height: deprecated.$s-1; - margin: 0; - border-top: $b-1 solid var(--dropdown-separator-color); -} - // Sections .sidebar-nav { margin: 0; @@ -579,22 +370,6 @@ stroke: var(--icon-foreground); } -.add-organization-icon { - @extend %button-icon; - - width: var(--sp-l); - height: var(--sp-l); - stroke: var(--sidebar-action-icon-color); -} - -.arrow-up-right-icon { - @extend %button-icon; - - width: var(--sp-m); - height: var(--sp-m); - stroke: var(--sidebar-action-icon-color); -} - .upgrade-plan-section { @include deprecated.button-style; @@ -625,156 +400,3 @@ color: var(--color-accent-tertiary); } - -.organizations-container { - align-items: center; - display: flex; - height: calc(2 * var(--sp-xxxl)); - max-height: calc(2 * var(--sp-xxxl)); - justify-content: space-between; - padding: 0 var(--sp-l); -} - -.selected-organization { - @include t.use-typography("body-medium"); - - color: var(--color-foreground-primary); - width: 100%; - padding-inline-start: var(--sp-s); - display: flex; - align-items: center; - gap: var(--sp-s); -} - -.create-organization { - margin-inline-start: auto; - text-transform: uppercase; -} - -.organization-penpot-icon { - display: flex; - justify-content: center; - align-items: center; - border-radius: 50%; - height: var(--sp-xxxl); - width: var(--sp-xxxl); - background-color: var(--color-foreground-primary); - - svg { - fill: var(--icon-stroke-color); - width: var(--sp-xxl); - height: var(--sp-xxl); - } -} - -.organization-icon { - display: flex; - justify-content: center; - align-items: center; - border-radius: 50%; - height: var(--sp-xxl); - width: var(--sp-xxl); - background-color: var(--color-foreground-primary); - - svg { - fill: var(--icon-stroke-color); - width: var(--sp-xl); - height: var(--sp-xl); - } -} - -.my-teams-icon { - height: var(--sp-xxl); - width: var(--sp-xxl); - - svg { - fill: var(--icon-stroke-color); - width: var(--sp-xxl); - height: var(--sp-xxl); - } -} - -.my-teams-icon-xxxl { - height: var(--sp-xxxl); - width: var(--sp-xxxl); - - svg { - fill: var(--icon-stroke-color); - width: var(--sp-xxxl); - height: var(--sp-xxxl); - } -} - -.sidebar-organization-switch { - position: relative; - width: 100%; -} - -.current-organization { - @include deprecated.button-style; - - text-transform: none; - display: grid; - align-items: center; - grid-template-columns: 1fr auto auto; - gap: var(--sp-s); - height: 100%; - padding: 0 0 0 px2rem(10); - width: 100%; -} - -.current-organization-no-options { - gap: 0; - padding: 0 px2rem(8) 0 px2rem(10); -} - -.current-organization .arrow-icon { - margin-inline-end: var(--sp-xs); -} - -.organization-options { - display: flex; - justify-content: center; - align-items: center; - max-width: var(--sp-xxl); - min-width: $sz-28; - height: 100%; -} - -.organization-switch-content { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - height: $sz-48; - width: 100%; -} - -.organization-options-btn { - --icon-stroke: var(--icon-foreground); - - display: flex; - justify-content: center; - align-items: center; - width: $sz-28; - height: 100%; - padding: 0; - border-radius: 0 $br-8 $br-8 0; - - &:hover { - --icon-stroke: var(--color-accent-primary); - - cursor: pointer; - } -} - -.organization-menu-icon { - @extend %button-icon; - - stroke: var(--icon-stroke); -} - -.organization-menu-icon-open { - @extend %button-icon; - - stroke: var(--color-accent-primary); -} diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index c4d697fd47..efe004275c 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -140,6 +140,7 @@ (def ^:icon-id eye-off "eye-off") (def ^:icon-id feedback "feedback") (def ^:icon-id fill-content "fill-content") +(def ^:icon-id files "files") (def ^:icon-id filter "filter") (def ^:icon-id fixed-width "fixed-width") (def ^:icon-id fit-content "fit-content") diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index f21a438542..2b08740f6a 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -9,11 +9,16 @@ [app.common.time :as ct] [app.common.uri :as u] [app.main.data.event :as ev] + [app.main.data.modal :as modal] [app.main.data.nitrate :as dnt] [app.main.data.nitrate-audit :as nitrate-audit] + [app.main.data.notifications :as ntf] + [app.main.data.team :as dt] [app.main.store :as st] [app.main.ui.auth.verify-token :as verify-token] - [cljs.test :as t :include-macros true])) + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) (t/deftest account-age-days-test (with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))] @@ -216,6 +221,105 @@ (t/is (string? dnt/go-to-subscription-url)) (t/is (not (u/uri? dnt/go-to-subscription-url))))) +(t/deftest organization-teams-filters-by-organization-id + (let [teams {"t1" {:id "t1" :organization {:id "org-a"}} + "t2" {:id "t2" :organization {:id "org-b"}} + "t3" {:id "t3" :is-default true} + "t4" {:id "t4" :organization {:id "org-a"}}}] + (t/is (= ["t1" "t4"] (map :id (dnt/organization-teams teams "org-a")))) + (t/is (= [] (dnt/organization-teams teams "org-c"))))) + +(t/deftest organization-leave-info-splits-owned-and-not-owned-teams + (let [org-teams [{:id "default" :is-default true} + {:id "owned-1" :permissions {:is-owner true}} + {:id "owned-2" :permissions {:is-owner true}} + {:id "member-1" :permissions {:is-owner false}}] + info (dnt/organization-leave-info org-teams)] + (t/is (= "default" (:default-team-id info))) + (t/is (= ["owned-1" "owned-2"] (map :id (:owned-teams info)))) + (t/is (= ["member-1"] (map :id (:not-owned-teams info)))))) + +(t/deftest transferable-teams-boundary-at-one-member + (let [owned-teams [{:id "solo" :members [{:id "m1"}]} + {:id "pair" :members [{:id "m1"} {:id "m2"}]} + {:id "empty" :members []}]] + (t/is (= ["pair"] (map :id (dnt/transferable-teams owned-teams)))))) + +(t/deftest leave-organization-fn-builds-delete-and-leave-lists + (let [captured (atom nil) + emitted (atom []) + owned-teams [{:id "solo" :members [{:id "m1"}]} + {:id "pair" :members [{:id "m1"} {:id "m2"}]}] + not-owned-teams [{:id "member-1" :name "extra"}] + leave-fn (dnt/leave-organization-fn {:organization {:id "org-1" :name "Acme"} + :default-team-id "default" + :owned-teams owned-teams + :not-owned-teams not-owned-teams + :on-error :on-error-fn})] + (with-redefs [dnt/leave-organization (fn [params] (reset! captured params) ::leave-event) + st/emit! (fn + ([event] (swap! emitted conj event)) + ([event & events] (swap! emitted into (cons event events))))] + + (t/testing "with no teams offered for transfer" + (leave-fn {:teams-to-transfer nil + :member-added-at "2026-07-17T00:00:00Z" + :organization-member-count-before 3}) + + (t/is (= [::leave-event] @emitted)) + (t/is (= {:id "org-1" + :name "Acme" + :default-team-id "default" + :teams-to-delete ["solo"] + :teams-to-leave [{:id "member-1"}] + :member-added-at "2026-07-17T00:00:00Z" + :organization-member-count-before 3 + :on-error :on-error-fn} + @captured))) + + (t/testing "folds transferred teams into teams-to-leave, ahead of the rest" + (leave-fn {:teams-to-transfer [{:id "pair" :reassign-to "new-owner"}] + :member-added-at "2026-07-17T00:00:00Z" + :organization-member-count-before 3}) + + (t/is (= [{:id "pair" :reassign-to "new-owner"} + {:id "member-1"}] + (:teams-to-leave @captured))))))) + +(t/deftest team-leave-on-error-matrix + (t/testing "known error code shows a translated notification" + (let [emitted (atom [])] + (->> (dnt/team-leave-on-error (ex-info "boom" {:code :owner-cant-leave-team})) + (rx/subs! #(swap! emitted conj %))) + (t/is (= 1 (count @emitted))) + (t/is (= :visible (:status (:notification (ptk/update (first @emitted) {}))))))) + + (t/testing "unknown error code rethrows the original error" + (let [error (ex-info "boom" {:code :something-unmapped}) + rejected (atom [])] + (->> (dnt/team-leave-on-error error) + (rx/subs! (fn [_]) #(swap! rejected conj %))) + (t/is (= [error] @rejected))))) + +(t/deftest org-leave-on-error-matrix + (t/testing "known error code refetches teams, hides the modal, and notifies" + (let [emitted (atom [])] + (->> (dnt/org-leave-on-error (ex-info "boom" {:code :not-valid-teams})) + (rx/subs! #(swap! emitted conj %))) + (t/is (= 3 (count @emitted))) + (t/is (some (ptk/type? ::dt/fetch-teams) @emitted)) + (t/is (some (ptk/type? ::modal/hide-modal) @emitted)) + (let [notification-event (first (filter (ptk/type? ::ntf/show) @emitted))] + (t/is (some? notification-event)) + (t/is (= :visible (:status (:notification (ptk/update notification-event {})))))))) + + (t/testing "unknown error code rethrows the original error" + (let [error (ex-info "boom" {:code :something-unmapped}) + rejected (atom [])] + (->> (dnt/org-leave-on-error error) + (rx/subs! (fn [_]) #(swap! rejected conj %))) + (t/is (= [error] @rejected))))) + (t/deftest build-admin-console-billing-url-encodes-string-callback (t/testing "billing callback query param round-trips as a real URL string" (let [public-uri (u/uri "https://localhost:3449/") diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 158a8fbc83..49d834faa0 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -93,6 +93,7 @@ [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] + [frontend-tests.ui.organization-team-switch-test] [frontend-tests.ui.routes-test] [frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-shortcuts-test] @@ -206,6 +207,7 @@ 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.ui.organization-team-switch-test 'frontend-tests.ui.routes-test 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test diff --git a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs new file mode 100644 index 0000000000..7deca67011 --- /dev/null +++ b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs @@ -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"}))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 90c8cbe2ee..6506243c4c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -454,7 +454,7 @@ msgstr "(copy)" #: src/app/main/ui/dashboard/sidebar.cljs:400 msgid "dashboard.create-new-organization" -msgstr "Create org" +msgstr "Create new organization" #: src/app/main/ui/dashboard/sidebar.cljs:477 msgid "dashboard.create-new-team" @@ -1137,10 +1137,6 @@ msgstr "Cannot parse the plugin manifest" msgid "dashboard.plugins.try-plugin" msgstr "Try plugin: " -#: src/app/main/ui/dashboard/sidebar.cljs:890 -msgid "dashboard.plus-create-new-organization" -msgstr "+ Create org" - #: src/app/main/data/dashboard.cljs:758 msgid "dashboard.progress-notification.deleting-files" msgstr "Deleting files…" @@ -10834,3 +10830,9 @@ msgstr "Autosaved versions will be kept for %s days." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" + +msgid "labels.team-management" +msgstr "Team management" + +msgid "dashboard.no-organizations-yet" +msgstr "You don't belong to any organization yet" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 03cf573dc3..93b54788af 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -461,7 +461,7 @@ msgstr "(copia)" #: src/app/main/ui/dashboard/sidebar.cljs:400 msgid "dashboard.create-new-organization" -msgstr "Crear org" +msgstr "Crear nueva organización" #: src/app/main/ui/dashboard/sidebar.cljs:477 msgid "dashboard.create-new-team" @@ -1137,10 +1137,6 @@ msgstr "No se puede analizar el manifiest de la extensión" msgid "dashboard.plugins.try-plugin" msgstr "Prueba la extensión: " -#: src/app/main/ui/dashboard/sidebar.cljs:890 -msgid "dashboard.plus-create-new-organization" -msgstr "+ Crear org" - #: src/app/main/data/dashboard.cljs:758 msgid "dashboard.progress-notification.deleting-files" msgstr "Eliminando archivos…" @@ -10474,3 +10470,9 @@ msgstr "Los autoguardados duran %s días." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" + +msgid "labels.team-management" +msgstr "Gestión de equipos" + +msgid "dashboard.no-organizations-yet" +msgstr "Todavía no perteneces a ninguna organización"