diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3036ecfcbe..cfa0ff9014 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -82,8 +82,9 @@ (def ^:private schema:organization [:map - [:id ::sm/text] - [:name ::sm/text]]) + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text]]) ;; TODO Unify with schemas on backend/src/app/http/management.clj (def ^:private schema:timestamp @@ -189,6 +190,9 @@ (defn add-nitrate-licence-to-profile + "Enriches a profile map with subscription information from Nitrate. + Adds a :subscription field containing the user's license details. + Returns the original profile unchanged if the request fails." [cfg profile] (try (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] @@ -199,11 +203,26 @@ :cause cause) profile))) -(defn add-org-to-team +(defn add-org-info-to-team + "Enriches a team map with organization information from Nitrate. + Adds organization-id, organization-name, organization-slug, and your-penpot fields. + Returns the original team unchanged if the request fails or org data is nil." [cfg team params] - (let [params (assoc (or params {}) :team-id (:id team)) - org (call cfg :get-team-org params)] - (assoc team :organization-id (:id org) :organization-name (:name org)))) + (try + (let [params (assoc (or params {}) :team-id (:id team)) + org (call cfg :get-team-org params)] + (if (some? org) + (assoc team + :organization-id (:id org) + :organization-name (:name org) + :organization-slug (:slug org) + :is-default (or (:is-default team) (true? (:isYourPenpot org)))) + team)) + (catch Throwable cause + (l/error :hint "failed to get team organization info" + :team-id (:id team) + :cause cause) + team))) (defn connectivity [cfg] diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 603e12187b..220602d4e9 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -193,7 +193,7 @@ (dm/with-open [conn (db/open pool)] (cond->> (get-teams conn profile-id) (contains? cf/flags :nitrate) - (map #(nitrate/add-org-to-team cfg % params))))) + (map #(nitrate/add-org-info-to-team cfg % params))))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 34c764249b..455b96705b 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -122,8 +122,7 @@ (set/difference cfeat/no-team-inheritable-features)) params {:profile-id profile-id :name "Default" - :features features - :is-default true} + :features features} team (db/tx-run! cfg teams/create-team params)] (select-keys team [:id]))) diff --git a/frontend/resources/images/icons/arrow-up-right.svg b/frontend/resources/images/icons/arrow-up-right.svg new file mode 100644 index 0000000000..56313c672b --- /dev/null +++ b/frontend/resources/images/icons/arrow-up-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index e118b1ec40..d9743c3543 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -17,12 +17,17 @@ (watch [_ _ _] (->> (rp/cmd! ::get-nitrate-connectivity {}) (rx/map (fn [connectivity] - (prn "connectivity" connectivity) (modal/show popup-type (or connectivity {})))))))) (defn go-to-nitrate-cc - [] - (st/emit! (rt/nav-raw :href "/control-center/"))) + ([] + (st/emit! (rt/nav-raw :href "/control-center/"))) + ([{:keys [organization-id organization-slug]}] + (let [href (dm/str "/control-center/org/" + (u/percent-encode organization-slug) + "/" + (u/percent-encode (str organization-id)))] + (st/emit! (rt/nav-raw :href href))))) (defn go-to-nitrate-billing [] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 040e127696..c93e48e711 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -78,6 +78,12 @@ (def ^:private exit-icon (deprecated-icon/icon-xref :exit (stl/css :exit-icon))) +(def ^:private add-org-icon + (deprecated-icon/icon-xref :add (stl/css :add-org-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") (mf/defc sidebar-project* @@ -285,13 +291,10 @@ :on-click on-clear-click} search-icon])])) -(mf/defc teams-selector-dropdown* +(mf/defc organizations-selector-dropdown* {::mf/private true} - [{:keys [team profile teams show-default-team allow-create-teams allow-create-org] :rest props}] - (let [on-create-team-click - (mf/use-fn #(st/emit! (modal/show :team-form {}))) - - on-team-click + [{:keys [organization organizations profile] :rest props}] + (let [on-org-click (mf/use-fn (fn [event] (let [team-id (-> (dom/get-current-target event) @@ -301,23 +304,93 @@ on-create-org-click (mf/use-fn + (mf/deps profile) (fn [] (if (dnt/is-valid-license? profile) (dnt/go-to-nitrate-cc) - (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] + (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) + + on-go-to-cc-click + (mf/use-fn + (mf/deps organization) + (fn [] + (dnt/go-to-nitrate-cc organization))) + + default-team-id (or (->> organizations + vals + (filter :is-default) + first + :id) + (:default-team-id profile)) + organizations (dissoc organizations default-team-id)] [:> dropdown-menu* props - (when show-default-team - [:> dropdown-menu-item* {:on-click on-team-click - :data-value (:default-team-id profile) - :class (stl/css :team-dropdown-item)} - [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] + [:> dropdown-menu-item* {:on-click on-org-click + :data-value default-team-id + :class (stl/css :org-dropdown-item)} + [:span {:class (stl/css :nitrate-org-icon)} + [:> raw-svg* {:id penpot-logo-icon}]] + "Penpot" + (when (= default-team-id (:id organization)) + tick-icon)] - [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] - (when (= (:default-team-id profile) (:id team)) + (for [org-item (remove :is-default (vals organizations))] + [:> dropdown-menu-item* {:on-click on-org-click + :data-value (:id org-item) + :class (stl/css :org-dropdown-item) + :key (str (:id org-item))} + ;; TODO org pictures + [:img {:src (cf/resolve-team-photo-url org-item) + :class (stl/css :team-picture) + :alt (:name org-item)}] + [:span {:class (stl/css :team-text) + :title (:name org-item)} (:name org-item)] + (when (= (:id org-item) (:id organization)) tick-icon)]) + [:hr {:role "separator" :class (stl/css :team-separator)}] + [:> dropdown-menu-item* {:on-click on-create-org-click + :class (stl/css :org-dropdown-item :action)} + [:span {:class (stl/css :icon-wrapper)} add-org-icon] + [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-org")]] + [:> dropdown-menu-item* {:on-click on-go-to-cc-click + :class (stl/css :org-dropdown-item :action)} + [:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon] + [:span {:class (stl/css :team-text)} (tr "dashboard.go-to-control-center")]]])) + +(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 #(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 profile) + :class (stl/css :team-dropdown-item)} + [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] + + [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] + (when (= (:default-team-id profile) (:id team)) + tick-icon)] + (for [team-item (remove :is-default (vals teams))] [:> dropdown-menu-item* {:on-click on-team-click :data-value (:id team-item) @@ -337,19 +410,12 @@ (when (= (:id team-item) (:id team)) tick-icon)]) - (when allow-create-teams - [: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")]]) - (when allow-create-org - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:> dropdown-menu-item* {:on-click on-create-org-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-org")]])])) + [: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} @@ -500,39 +566,41 @@ (tr "dashboard.delete-team")])])) +(defn- team->org [team] + (assoc (dm/select-keys team [:id :organization-id :organization-slug]) + :name (:organization-name team))) + (mf/defc sidebar-org-switch* [{:keys [team profile]}] (let [teams (mf/deref refs/teams) + + ;; Find the "your-penpot" teams, and transform them in orgs orgs (mf/with-memo [teams] - (let [orgs (->> teams - vals - (group-by :organization-id) - (map (fn [[_group entries]] (first entries))) - vec - (d/index-by :id))] - (update-vals orgs - (fn [t] - (assoc t :name (str "ORG: " (:organization-name t))))))) + (->> teams + vals + (filter :is-default) + (map team->org) + (d/index-by :id))) - empty? (= (count orgs) 1) + no-orgs? (= (count orgs) 0) + current-org (team->org team) - current-org (mf/with-memo [team] - (assoc team :name (str "ORG: " (:organization-name team)))) + default-org? (= (:default-team-id profile) (:id current-org)) - show-teams-menu* + show-orgs-menu* (mf/use-state false) - show-teams-menu? - (deref show-teams-menu*) + show-orgs-menu? + (deref show-orgs-menu*) - on-show-teams-click + on-show-orgs-click (mf/use-fn (fn [event] (dom/stop-propagation event) - (swap! show-teams-menu* not))) + (swap! show-orgs-menu* not))) - on-show-teams-keydown + on-show-orgs-keydown (mf/use-fn (fn [event] (when (or (kbd/space? event) @@ -541,51 +609,58 @@ (dom/stop-propagation event) (some-> (dom/get-current-target event) (dom/click))))) - close-teams-menu - (mf/use-fn #(reset! show-teams-menu* false)) + close-orgs-menu + (mf/use-fn #(reset! show-orgs-menu* false)) on-create-org-click (mf/use-fn + (mf/deps profile) (fn [] (if (dnt/is-valid-license? profile) (dnt/go-to-nitrate-cc) (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] - (if empty? - [:div {:class (stl/css :nitrate-orgs-empty)} + (if no-orgs? + [:div {:class (stl/css :nitrate-selected-org)} [:span {:class (stl/css :nitrate-penpot-icon)} [:> raw-svg* {:id penpot-logo-icon}]] "Penpot" [:> button* {:variant "ghost" :type "button" :class (stl/css :nitrate-create-org) - :on-click on-create-org-click} (tr "dashboard.create-new-org")]] + :on-click on-create-org-click} (tr "dashboard.plus-create-new-org")]] - [: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} + [:div {:class (stl/css :sidebar-org-switch)} - [:div {:class (stl/css :team-name)} - [:img {:src (cf/resolve-team-photo-url current-org) - :class (stl/css :team-picture) - :alt (:name current-org)}] - [:span {:class (stl/css :team-text) :title (:name current-org)} (:name current-org)]] + [:button {:class (stl/css :current-org) + :on-click on-show-orgs-click + :on-key-down on-show-orgs-keydown + :aria-expanded show-orgs-menu? + :aria-haspopup "menu"} + [:div {:class (stl/css :team-name)} + (if default-org? + [:* + [:span {:class (stl/css :nitrate-penpot-icon)} + [:> raw-svg* {:id penpot-logo-icon}]] + [:span {:class (stl/css :team-text)} + "Penpot"]] + [:* + [:span {:class (stl/css :nitrate-penpot-icon)} + ;; TODO org pictures + [:img {:src (cf/resolve-team-photo-url current-org) + :class (stl/css :team-picture) + :alt (:name current-org)}]] + [:span {:class (stl/css :team-text)} + (:name current-org)]])] + arrow-icon] - arrow-icon]] - - ;; Teams Dropdown - - [:> teams-selector-dropdown* {:show show-teams-menu? - :on-close close-teams-menu - :id "organizations-list" - :class (stl/css :dropdown :teams-dropdown) - :team current-org - :profile profile - :teams orgs - :show-default-team false - :allow-create-teams false - :allow-create-org true}]]))) + ;; Orgs Dropdown + [:> organizations-selector-dropdown* {:show show-orgs-menu? + :on-close close-orgs-menu + :id "organizations-list" + :class (stl/css :dropdown :teams-dropdown) + :organization current-org + :profile profile + :organizations orgs}]]))) (mf/defc sidebar-team-switch* [{:keys [team profile]}] @@ -593,7 +668,9 @@ org-id (when nitrate? (:organization-id team)) teams (cond->> (mf/deref refs/teams) nitrate? - (filter #(= (-> % val :organization-id) org-id))) + (filter #(= (-> % val :organization-id) org-id)) + nitrate? + (into {})) subscription (get team :subscription) @@ -613,6 +690,9 @@ show-teams-menu? (deref show-teams-menu*) + is-default? + (:is-default team) + on-show-teams-click (mf/use-fn (fn [event] @@ -656,15 +736,17 @@ [: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} + :on-key-down on-show-teams-keydown + :aria-expanded show-teams-menu? + :aria-haspopup "menu"} (cond - (:is-default team) + is-default? [:div {:class (stl/css :team-name)} [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] [:span {:class (stl/css :team-text)} (tr "dashboard.default-team-name")]] (and (contains? cf/flags :subscriptions) - (not (:is-default team)) + (not is-default?) (or (= "unlimited" subscription-type) (= "enterprise" subscription-type))) [:div {:class (stl/css :team-name)} [:img {:src (cf/resolve-team-photo-url team) @@ -675,7 +757,7 @@ [:> menu-team-icon* {:subscription-type subscription-type}]]] - (and (not (:is-default team)) + (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) @@ -685,7 +767,7 @@ arrow-icon] - (when-not (:is-default team) + (when-not is-default? [:button {:class (stl/css :switch-options) :on-click on-show-options-click :aria-label "team-management" @@ -701,10 +783,7 @@ :class (stl/css :dropdown :teams-dropdown) :team team :profile profile - :teams teams - :show-default-team true - :allow-create-teams true - :allow-create-org false}] + :teams teams}] [:> team-options-dropdown* {:show show-team-options-menu? :on-close close-team-options-menu diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 57a0a97ce7..5adcb89a04 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -172,6 +172,14 @@ height: $sz-40; } +.org-dropdown-item { + @extend .menu-item-base; + display: grid; + grid-template-columns: var(--sp-xxxl) 1fr auto; + gap: var(--sp-s); + height: $sz-40; +} + .action { --sidebar-action-icon-color: var(--icon-foreground); --sidebar-icon-backgroun-color: var(--color-background-secondary); @@ -492,6 +500,20 @@ stroke: var(--icon-foreground); } +.add-org-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.buttonStyle; display: flex; @@ -530,7 +552,7 @@ // border-block-end: $b-1 solid var(--color-background-quaternary); } -.nitrate-orgs-empty { +.nitrate-selected-org { @include t.use-typography("body-medium"); color: var(--color-foreground-primary); width: 100%; @@ -560,3 +582,35 @@ height: var(--sp-xxl); } } + +.nitrate-org-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); + } +} + +.sidebar-org-switch { + position: relative; + width: 100%; +} + +.current-org { + @include deprecated.buttonStyle; + display: grid; + align-items: center; + grid-template-columns: 1fr auto; + gap: var(--sp-s); + height: 100%; + width: 100%; + padding: 0 var(--sp-m); +} 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 4f44ce4429..0e83173bee 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -54,6 +54,7 @@ (def ^:icon-id arrow-left "arrow-left") (def ^:icon-id arrow-right "arrow-right") (def ^:icon-id arrow-up "arrow-up") +(def ^:icon-id arrow-up-right "arrow-up-right") (def ^:icon-id asc-sort "asc-sort") (def ^:icon-id at "at") (def ^:icon-id board "board") diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e29a378cbe..96c544612c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -359,9 +359,15 @@ msgid "dashboard.copy-suffix" msgstr "(copy)" #: src/app/main/ui/dashboard/sidebar.cljs:347 -msgid "dashboard.create-new-org" +msgid "dashboard.plus-create-new-org" msgstr "+ Create org" +msgid "dashboard.create-new-org" +msgstr "Create org" + +msgid "dashboard.go-to-control-center" +msgstr "Go to Control Center" + #: src/app/main/ui/dashboard/sidebar.cljs:340 msgid "dashboard.create-new-team" msgstr "Create new team" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index a3123f8289..105d279647 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -368,9 +368,15 @@ msgid "dashboard.copy-suffix" msgstr "(copia)" #: src/app/main/ui/dashboard/sidebar.cljs:347 -msgid "dashboard.create-new-org" +msgid "dashboard.plus-create-new-org" msgstr "+ Crear org" +msgid "dashboard.create-new-org" +msgstr "Crear org" + +msgid "dashboard.go-to-control-center" +msgstr "Ir al centro de control" + #: src/app/main/ui/dashboard/sidebar.cljs:340 msgid "dashboard.create-new-team" msgstr "Crear nuevo equipo" @@ -2171,7 +2177,7 @@ msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como #: src/app/main/ui/settings/integrations.cljs:368 msgid "integrations.mcp-server.status.expired.1" -msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave." +msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave." #: src/app/main/ui/settings/integrations.cljs:415 msgid "integrations.mcp-server.mcp-keys.copy"