From a206d57443b3a36af87cb421806f538f54da9f83 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 15 Apr 2026 22:31:44 +0200 Subject: [PATCH] :sparkles: Add team to a nitrate organization --- backend/src/app/nitrate.clj | 4 +- backend/src/app/rpc/commands/nitrate.clj | 80 +++++++++++-- backend/src/app/rpc/commands/teams.clj | 37 +++--- .../test/backend_tests/rpc_nitrate_test.clj | 5 +- frontend/src/app/main/data/nitrate.cljs | 18 ++- frontend/src/app/main/data/team.cljs | 9 ++ .../app/main/ui/components/org_avatar.cljs | 13 +- .../app/main/ui/components/org_avatar.scss | 5 + .../src/app/main/ui/dashboard/sidebar.cljs | 41 +++---- frontend/src/app/main/ui/dashboard/team.cljs | 113 +++++++++++++++++- frontend/src/app/main/ui/dashboard/team.scss | 31 +++++ .../src/app/main/ui/ds/controls/combobox.cljs | 34 ++++-- .../src/app/main/ui/ds/controls/combobox.mdx | 33 +++++ .../src/app/main/ui/ds/controls/combobox.scss | 5 + .../main/ui/ds/controls/shared/option.cljs | 17 ++- .../main/ui/ds/controls/shared/option.scss | 5 + .../ds/controls/shared/options_dropdown.cljs | 5 + .../ui/ds/controls/shared/render_option.cljs | 1 + frontend/translations/en.po | 18 +++ frontend/translations/es.po | 18 +++ 20 files changed, 414 insertions(+), 78 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 48374660e0..9aaff500a3 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -131,8 +131,8 @@ (def ^:private schema:profile-org [:map [:is-member :boolean] - [:organization-id ::sm/uuid] - [:default-team-id [:maybe ::sm/uuid]]]) + [:organization-id {:optional true} [:maybe ::sm/uuid]] + [:default-team-id {:optional true} [:maybe ::sm/uuid]]]) ;; TODO Unify with schemas on backend/src/app/http/management.clj diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 71eaedb2b0..62d25a781b 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -12,6 +12,31 @@ [app.util.services :as sv])) + +(defn assert-is-owner [cfg profile-id team-id] + (let [perms (teams/get-permissions cfg profile-id team-id)] + (when-not (:is-owner perms) + (ex/raise :type :validation + :code :insufficient-permissions)))) + +(defn assert-not-default-team [cfg team-id] + (let [team (teams/get-team-info cfg {:id team-id})] + (when (:is-default team) + (ex/raise :type :validation + :code :cant-move-default-team)))) + +(defn assert-membership [cfg profile-id organization-id] + (let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id + :org-id organization-id})] + (when-not (:organization-id membership) + (ex/raise :type :validation + :code :organization-doesnt-exists)) + + (when-not (:is-member membership) + (ex/raise :type :validation + :code :user-doesnt-belong-organization)))) + + (def schema:connectivity [:map {:title "nitrate-connectivity"} [:licenses ::sm/boolean]]) @@ -151,6 +176,8 @@ (ex/raise :type :validation :code :not-valid-teams)) + (assert-membership cfg profile-id org-id) + ;; delete the teams-to-delete (doseq [id teams-to-delete] (teams/delete-team cfg {:profile-id profile-id :team-id id})) @@ -175,25 +202,52 @@ (def ^:private schema:remove-team-from-org [:map [:team-id ::sm/uuid] - [:organization-id ::sm/uuid]]) + [:organization-id ::sm/uuid] + [:organization-name ::sm/text]]) (sv/defmethod ::remove-team-from-org {::doc/added "2.16" ::sm/params schema:remove-team-from-org} [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] - (let [perms (teams/get-permissions cfg profile-id team-id) - team (teams/get-team-info cfg {:id team-id})] - (when-not (:is-owner perms) - (ex/raise :type :validation - :code :insufficient-permissions)) + (assert-is-owner cfg profile-id team-id) + (assert-not-default-team cfg team-id) + (assert-membership cfg profile-id organization-id) - (when (:is-default team) - (ex/raise :type :validation - :code :cant-remove-default-team)) + ;; Api call to nitrate + (nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id}) - ;; Api call to nitrate - (nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id}) + ;; Notify connected users + (notifications/notify-team-change cfg team-id nil nil organization-name "dashboard.team-no-longer-belong-org") + nil) - (notifications/notify-team-change cfg team-id nil nil organization-name "dashboard.team-no-longer-belong-org") - nil)) \ No newline at end of file + +(def ^:private schema:add-team-to-org + [:map + [:team-id ::sm/uuid] + [:organization-id ::sm/uuid] + [:organization-name ::sm/text]]) + +(sv/defmethod ::add-team-to-org + {::rpc/auth true + ::doc/added "2.16" + ::sm/params schema:add-team-to-org + ::db/transaction true} + [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] + + (assert-is-owner cfg profile-id team-id) + (assert-not-default-team cfg team-id) + (assert-membership cfg profile-id organization-id) + + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] + ;; Add teammates to the org if needed + (doseq [{member-id :profile-id} team-members + :when (not= member-id profile-id)] + (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) + + ;; Api call to nitrate + (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false}) + + ;; Notify connected users + (notifications/notify-team-change cfg team-id nil organization-id organization-name "dashboard.team-belong-org") + nil) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 9b089e3b2c..722b48bb61 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -551,20 +551,29 @@ (db/tx-run! cfg (fn [{:keys [::db/conn] :as tx-cfg}] - (let [org-id org-id - default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id org-id) - default-team-id (:id default-team) - result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id - :team-id default-team-id - :org-id org-id} - (some? email) (assoc :email email)))] - (when (not (:is-member result)) - (ex/raise :type :internal - :code :failed-add-profile-org-nitrate - :context {:profile-id profile-id - :org-id org-id - :default-team-id default-team-id})) - default-team-id)))))) + + (let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id + :org-id org-id})] + ;; Only when the user doesn't belong to the organization yet + (when (and + (some? (:organization-id membership)) ;; the organization exists + (not (:is-member membership))) ;; the user is not a member of the org yet + + + (let [org-id org-id + default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id org-id) + default-team-id (:id default-team) + result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id + :team-id default-team-id + :org-id org-id} + (some? email) (assoc :email email)))] + (when (not (:is-member result)) + (ex/raise :type :internal + :code :failed-add-profile-org-nitrate + :context {:profile-id profile-id + :org-id org-id + :default-team-id default-team-id})) + default-team-id)))))))) (defn add-profile-to-team! ([cfg params] diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index d098013aa5..084c8417a8 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -33,11 +33,14 @@ (defn- nitrate-call-mock "Creates a mock for nitrate/call that returns the given org-summary for - :get-org-summary and nil for any other method." + :get-org-summary, a valid membership for :get-org-membership, and nil for + any other method." [org-summary] (fn [_cfg method _params] (case method :get-org-summary org-summary + :get-org-membership {:is-member true + :organization-id (:id org-summary)} nil))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index ba7124a5a2..c6ece1a5fe 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -90,13 +90,13 @@ (dm/get-in profile [:subscription :status])))) (defn leave-org - [{:keys [org-id org-name default-team-id teams-to-delete teams-to-leave on-error] :as params}] + [{:keys [id org-name default-team-id teams-to-delete teams-to-leave on-error] :as params}] (ptk/reify ::leave-org ptk/WatchEvent (watch [_ state _] (let [profile-team-id (dm/get-in state [:profile :default-team-id])] - (->> (rp/cmd! ::leave-org {:org-id org-id + (->> (rp/cmd! ::leave-org {:org-id id :org-name org-name :default-team-id default-team-id :teams-to-delete teams-to-delete @@ -121,5 +121,15 @@ (->> (rp/cmd! ::remove-team-from-org {:team-id team-id :organization-id organization-id :organization-name organization-name}) (rx/mapcat (fn [_] - (rx/of - (modal/hide)))))))) \ No newline at end of file + (rx/of (modal/hide)))))))) + + +(defn add-team-to-org + [{:keys [team-id organization-id organization-name] :as params}] + (ptk/reify ::add-team-to-org + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! ::add-team-to-org {:team-id team-id :organization-id organization-id :organization-name organization-name}) + (rx/mapcat + (fn [_] + (rx/of (modal/hide)))))))) \ No newline at end of file diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 0756e71823..57fb1299ca 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -588,3 +588,12 @@ (rx/map shared-files-fetched))))))) +(defn team->organization [team] + {:id (:organization-id team) + :slug (:organization-slug team) + :owner-id (:organization-owner-id team) + :avatar-bg-url (:organization-avatar-bg-url team) + :custom-photo (:organization-custom-photo team) + :name (:organization-name team) + :default-team-id (:id team)}) + diff --git a/frontend/src/app/main/ui/components/org_avatar.cljs b/frontend/src/app/main/ui/components/org_avatar.cljs index c4430af12d..43521a1dd7 100644 --- a/frontend/src/app/main/ui/components/org_avatar.cljs +++ b/frontend/src/app/main/ui/components/org_avatar.cljs @@ -14,8 +14,8 @@ {::mf/props :obj} [{:keys [org size]}] (let [name (:name org) - custom-photo (:organization-custom-photo org) - avatar-bg (:organization-avatar-bg-url org) + custom-photo (:custom-photo org) + avatar-bg (:avatar-bg-url org) initials (d/get-initials name)] (if custom-photo @@ -23,11 +23,13 @@ :class (stl/css-case :org-avatar true :org-avatar-custom true :org-avatar-xxxl (= size "xxxl") - :org-avatar-xxl (= size "xxl")) + :org-avatar-xxl (= size "xxl") + :org-avatar-xl (= size "xl")) :alt name}] [:div {:class (stl/css-case :org-avatar true :org-avatar-xxxl (= size "xxxl") - :org-avatar-xxl (= size "xxl")) + :org-avatar-xxl (= size "xxl") + :org-avatar-xl (= size "xl")) :aria-hidden "true"} [:img {:src avatar-bg :class (stl/css :org-avatar-bg) @@ -35,5 +37,6 @@ (when (seq initials) [:span {:class (stl/css-case :org-avatar-initials true :size-initials-xxxl (= size "xxxl") - :size-initials-xxl (= size "xxl"))} + :size-initials-xxl (= size "xxl") + :size-initials-xxl (= size "xl"))} ;; Keep the initials as xxl to make them legible initials])]))) diff --git a/frontend/src/app/main/ui/components/org_avatar.scss b/frontend/src/app/main/ui/components/org_avatar.scss index ab7ee242a1..b72591568b 100644 --- a/frontend/src/app/main/ui/components/org_avatar.scss +++ b/frontend/src/app/main/ui/components/org_avatar.scss @@ -28,6 +28,11 @@ height: var(--sp-xxl); } +.org-avatar-xl { + width: var(--sp-xl); + height: var(--sp-xl); +} + .org-avatar-bg { position: absolute; inset: 0; diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 9e8de37d3e..3ae14e1281 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -322,18 +322,16 @@ (mf/deps organization profile) (fn [] ;; Navigate to active org if user owns it, otherwise to last visited org - (if (and (:organization-id organization) - (= (:id profile) (:organization-owner-id organization))) + (if (and (:id organization) + (= (:id profile) (:owner-id organization))) (dnt/go-to-nitrate-cc organization) (dnt/go-to-nitrate-cc)))) - default-team-id (or (->> organizations - vals - (filter :is-default) - first - :id) + empty-org (d/seek #(nil? (:id %)) organizations) + default-team-id (or (:default-team-id empty-org) (:default-team-id profile)) - organizations (dissoc organizations default-team-id) + + organizations (filter :id organizations) is-valid-license? (dnt/is-valid-license? profile)] @@ -348,15 +346,15 @@ (when (= default-team-id (:default-team-id organization)) tick-icon)] - (for [org-item (remove :is-default (vals organizations))] + (for [org-item organizations] [:> dropdown-menu-item* {:on-click on-org-click - :data-value (:id org-item) + :data-value (:default-team-id org-item) :class (stl/css :org-dropdown-item) - :key (str (:id org-item))} + :key (str (:default-team-id org-item))} [:> org-avatar* {:org org-item :size "xxl"}] [:span {:class (stl/css :team-text) :title (:name org-item)} (:name org-item)] - (when (= (:id org-item) (:default-team-id organization)) + (when (= (:default-team-id org-item) (:default-team-id organization)) tick-icon)]) [:hr {:role "separator" :class (stl/css :team-separator)}] @@ -642,7 +640,7 @@ (concat teams-to-transfer)) teams-to-delete (map :id teams-to-delete)] - (st/emit! (dnt/leave-org {:org-id (:organization-id organization) + (st/emit! (dnt/leave-org {:id (:id organization) :default-team-id default-team-id :teams-to-delete teams-to-delete :teams-to-leave teams-to-leave @@ -691,11 +689,6 @@ (tr "dashboard.leave-org")]])) -(defn- team->org [team] - (assoc (dm/select-keys team [:id :organization-id :organization-slug :organization-owner-id :organization-avatar-bg-url]) - :name (:organization-name team) - :default-team-id (:id team))) - (mf/defc sidebar-org-switch* [{:keys [team profile]}] (let [teams (mf/deref refs/teams) @@ -705,20 +698,20 @@ (->> teams vals (filter :is-default) - (map team->org) + (map dtm/team->organization) (d/index-by :id))) show-dropdown? (or (dnt/is-valid-license? profile) (> (count orgs) 1)) - current-org (team->org team) + current-org (dtm/team->organization team) org-teams (mf/with-memo [teams current-org] (->> teams vals - (filter #(= (:organization-id %) (:organization-id current-org))))) + (filter #(= (:organization-id %) (:id current-org))))) - default-org? (nil? (:organization-id current-org)) + default-org? (nil? (:id current-org)) show-orgs-menu* (mf/use-state false) @@ -787,7 +780,7 @@ (:name current-org)]])] arrow-icon] (if (or default-org? - (= (:id profile) (:organization-owner-id current-org))) + (= (:id profile) (:owner-id current-org))) [:div {:class (stl/css :org-options)}] [:> button* {:variant "ghost" :type "button" @@ -803,7 +796,7 @@ :class (stl/css :dropdown :teams-dropdown) :organization current-org :profile profile - :organizations orgs}] + :organizations (vals orgs)}] ;; Orgs options [:> org-options-dropdown* {:show show-org-options-menu? :on-close close-org-options-menu diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 6dc642c40d..2643916289 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -30,11 +30,13 @@ [app.main.ui.dashboard.team-form] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.combobox :refer [combobox*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] + [app.util.forms :as uforms] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -792,6 +794,77 @@ (tr "labels.continue") (tr "labels.resend"))]]]]]) + +(def schema:organization-form [:map {:title "SelectOrgForm"} + [:selected-id ::sm/uuid]]) + +(mf/defc render-org-combobox-avatar* + [{:keys [avatar]}] + [:> org-avatar* {:org (:organization avatar) + :size (:size avatar)}]) + +(mf/defc select-organization-modal + {::mf/register modal/components + ::mf/register-as :select-organization-modal} + [{:keys [organizations on-confirm]}] + (let [options (mf/with-memo [organizations] + (mapv (fn [organization] + {:id (str (:id organization)) + :label (:name organization) + :avatar {:render-fn render-org-combobox-avatar* + :organization organization + :size "xl"}}) + organizations)) + + form (fm/use-form :schema schema:organization-form :initial {}) + + on-change + (mf/use-fn + (mf/deps form) + (fn [id] + (uforms/on-input-change form :selected-id id))) + + on-confirm' + (mf/use-fn + (mf/deps on-confirm form) + (fn [] + (on-confirm (dm/get-in @form [:clean-data :selected-id]))))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-select-org-container :modal-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-select-org-title)} + (tr "dashboard.select-org-modal.title")] + + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + + [:div + [:div {:class (stl/css :modal-select-org-content)} + (tr "dashboard.select-org-modal.choose")] + [:> combobox* {:id "selected-id" + :class (stl/css :team-member) + :options options + :default-selected (or (some-> (get-in @form [:data :selected-id]) str) "") + :placeholder (tr "dashboard.select-org-modal.select") + :on-change on-change}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)} + + [:> button* + {:class (stl/css :cancel-button) + :variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] + [:> button* + {:class (stl/css :accept-btn) + :variant "primary" + :type "button" + :disabled (not (:valid @form)) + :on-click on-confirm'} + (tr "dashboard.select-org-modal.accept")]]]]])) + (mf/defc invitation-section* {::mf/props :obj ::mf/private true} @@ -1291,6 +1364,13 @@ can-edit (or (:is-owner permissions) (:is-admin permissions)) + organizations (mf/deref refs/teams) + organizations (mf/with-memo [organizations] + (->> (vals organizations) + (filter :is-default) + (filter :organization-id) + (map dtm/team->organization))) + show-org-options-menu* (mf/use-state false) @@ -1333,7 +1413,24 @@ :accept-label (tr "modals.remove-team-org.accept") :on-accept remove-team-from-org-fn :accept-style :danger}] - (st/emit! (modal/show params)))))] + (st/emit! (modal/show params))))) + + on-add-team-to-org-confirm + (mf/use-fn + (mf/deps team) + (fn [organization-id] + (let [organization (d/seek #(= organization-id (:id %)) organizations)] + (when organization + (st/emit! (dnt/add-team-to-org {:team-id (:id team) + :organization-id organization-id + :organization-name (:name organization)})))))) + + on-add-team-to-org + (mf/use-fn + (mf/deps organizations on-add-team-to-org-confirm) + (fn [] + (st/emit! (modal/show :select-organization-modal {:organizations organizations + :on-confirm on-add-team-to-org-confirm}))))] (mf/with-effect [team] (dom/set-html-title (tr "title.team-settings" @@ -1374,7 +1471,7 @@ (if (:organization-id team) [:div {:class (stl/css :block-content)} [:div {:class (stl/css :org-block-content)} - [:> org-avatar* {:org team :size "xxxl"}] + [:> org-avatar* {:org (dtm/team->organization team) :size "xxxl"}] [:span {:class (stl/css :block-text)} (:organization-name team)] @@ -1392,9 +1489,15 @@ [:li {:on-click on-remove-team-from-org :class (stl/css :org-dropdown-item)} (tr "dashboard.team-organization.remove")]]]]])]] - [:div {:class (stl/css :block-content)} - [:span {:class (stl/css :block-text)} - (tr "dashboard.team-organization.none")]])]) + [:* + [:div {:class (stl/css :block-content)} + [:span {:class (stl/css :block-text)} + (tr "dashboard.team-organization.none")]] + (when (and (pos? (count organizations)) + (not (:is-default team))) + [:div {:class (stl/css :block-content)} + [:span {:class (stl/css :block-text)} + [:a {:on-click on-add-team-to-org} (tr "dashboard.team-organization.add")]]])])]) [:div {:class (stl/css :block)} [:div {:class (stl/css :block-label)} diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index b04c895bdd..73e8b56f6a 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -871,6 +871,33 @@ gap: var(--sp-s); } +// SELECT ORGANIZATION MODAL + +.modal-select-org-container { + overflow: hidden; + display: flex; + flex-direction: column; + width: $sz-512; +} + +.modal-select-org-content { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-secondary); + overflow: auto; + margin-block-end: var(--sp-s); +} + +.modal-select-org-title { + @include t.use-typography("title-medium"); + + color: var(--color-foreground-primary); + text-transform: uppercase; + height: $sz-40; +} + +// ORGANIZATIONS SETTINGS + .org-block-content { display: grid; grid-template-columns: var(--sp-xxxl) 1fr var(--sp-xxxl); @@ -948,3 +975,7 @@ background-color: var(--color-background-quaternary); } } + +a { + color: var(--modal-link-foreground-color); +} diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index f8fcc566b6..c5a74811d2 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -10,7 +10,7 @@ (:require [app.common.data :as d] [app.main.constants :refer [max-input-length]] - [app.main.ui.ds.controls.select :refer [get-option handle-focus-change]] + [app.main.ui.ds.controls.select :refer [handle-focus-change]] [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] @@ -67,7 +67,7 @@ (mf/with-memo [options filter-id] (->> options (filterv (fn [option] - (let [option (str/lower (get option :id)) + (let [option (str/lower (get option :label)) filter (str/lower filter-id)] (str/includes? option filter)))) (not-empty))) @@ -113,7 +113,7 @@ on-blur (mf/use-fn - (mf/deps on-change) + (mf/deps on-change options selected-id) (fn [event] (dom/stop-propagation event) (let [target (dom/get-related-target event) @@ -123,7 +123,12 @@ (reset! focused-id* nil) (when (fn? on-change) (when-let [input-node (mf/ref-val input-ref)] - (on-change (dom/get-input-value input-node)))))))) + (let [input-value (dom/get-input-value input-node) + selected-option (d/seek #(= selected-id (get % :id)) options) + value (if (some? selected-option) + selected-id + input-value)] + (on-change value)))))))) on-input-click (mf/use-fn @@ -209,11 +214,19 @@ selected-option (mf/with-memo [options selected-id] (when (d/not-empty? options) - (get-option options selected-id))) + (d/seek #(= selected-id (get % :id)) options))) icon (when selected-option - (get selected-option :icon))] + (get selected-option :icon)) + + avatar + (when selected-option + (get selected-option :avatar)) + + render-avatar-fn + (when avatar + (get avatar :render-fn))] (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) @@ -241,11 +254,14 @@ :on-click on-click} [:span {:class (stl/css-case :header true - :header-icon (some? icon))} + :header-icon (some? icon) + :header-avatar (fn? render-avatar-fn))} (when icon [:> icon* {:icon-id icon :size "s" :aria-hidden true}]) + (when (fn? render-avatar-fn) + [:> render-avatar-fn {:avatar avatar}]) [:input {:id id :ref input-ref :type "text" @@ -259,7 +275,9 @@ :data-testid "combobox-input" :max-length (d/nilv max-length max-input-length) :disabled disabled - :value (d/nilv selected-id "") + :value (if (str/empty? (:id selected-option)) + (d/nilv selected-id "") + (d/nilv (:label selected-option) "")) :placeholder placeholder :on-change on-input-change :on-click on-input-click diff --git a/frontend/src/app/main/ui/ds/controls/combobox.mdx b/frontend/src/app/main/ui/ds/controls/combobox.mdx index eff4b6b18d..58075b8aed 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.mdx +++ b/frontend/src/app/main/ui/ds/controls/combobox.mdx @@ -53,6 +53,39 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace. ]}] ``` + +### Avatars + +Each option of `combobox*` also accepts an optional `avatar` map. +Avatar rendering is defined per option with `:render-fn`, so each avatar type can provide its own UI. The renderer should be a component function that receives the full `avatar` map. + +```clj +;; Example renderer for organization avatars +(mf/defc render-org-avatar* + [{:keys [avatar]}] + (when (= :organization (:type avatar)) + [:> org-avatar* {:org (:organization avatar) + :size (:size avatar)}])) + +[:> combobox* + {:options [{:label "Design Team" + :id "org-design" + :avatar {:render-fn render-org-avatar* + :size "s" + :organization {:name "Design Team" + :organization-avatar-bg-url "https://example.com/avatar-bg.svg" + :organization-custom-photo nil}}} + {:label "Engineering" + :id "org-engineering" + :avatar {:render-fn render-org-avatar* + :size "s" + :organization {:name "Engineering" + :organization-avatar-bg-url nil + :organization-custom-photo "https://example.com/custom-photo.png"}}}]}] +``` + +The same pattern can be used later for other avatar kinds, for example `:team`, by adding a different `:render-fn` in those options. + ## Usage guidelines (design) ### Where to Use diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index 70ad818514..519243a8fb 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -64,6 +64,11 @@ color: var(--combobox-icon-color); } +.header-avatar { + grid-template-columns: auto 1fr; + gap: var(--sp-s); +} + .input { all: unset; diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.cljs b/frontend/src/app/main/ui/ds/controls/shared/option.cljs index 0542268bc1..b313bf4263 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/option.cljs @@ -22,6 +22,12 @@ [:focused {:optional true} :boolean] [:dimmed {:optional true} :boolean] [:label {:optional true} :string] + [:avatar {:optional true} + [:maybe + [:map + [:size {:optional true} :string] + [:organization {:optional true} :any] + [:render-fn {:optional true} fn?]]]] [:aria-label {:optional true} [:maybe :string]] [:on-click {:optional true} fn?]] [:fn {:error/message "invalid data: missing required props"} @@ -33,9 +39,13 @@ (mf/defc option* {::mf/schema schema:option} - [{:keys [id ref label icon aria-label on-click selected focused dimmed] :rest props}] - (let [class (stl/css-case :option true + [{:keys [id ref label icon avatar aria-label on-click selected focused dimmed] :rest props}] + (let [render-avatar-fn (when avatar + (get avatar :render-fn)) + + class (stl/css-case :option true :option-with-icon (some? icon) + :option-with-avatar (fn? render-avatar-fn) :option-selected selected :option-current focused)] @@ -57,6 +67,9 @@ :aria-hidden (when label true) :aria-label (when (not label) aria-label)}]) + (when (fn? render-avatar-fn) + [:> render-avatar-fn {:avatar avatar}]) + [:span {:class (stl/css-case :option-text true :option-text-dimmed dimmed)} label] diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index bc693a14d4..2b3e749770 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -36,6 +36,11 @@ grid-template-columns: auto 1fr auto; } +.option-with-avatar { + grid-template-columns: auto 1fr auto; + gap: var(--sp-s); +} + .option-text { white-space: nowrap; overflow: hidden; diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index e3f7b778d7..e868e4ab2d 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -37,6 +37,11 @@ [:value {:optional true} :keyword] [:icon {:optional true} schema:icon-list] [:label {:optional true} :string] + [:avatar {:optional true} + [:map + [:size {:optional true} :string] + [:organization {:optional true} :any] + [:render-fn {:optional true} fn?]]] [:aria-label {:optional true} :string]]) (def ^:private schema:options-dropdown diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs index b3dec0b710..8237f63f81 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs @@ -60,6 +60,7 @@ :label (get option :label) :aria-label (get option :aria-label) :icon (get option :icon) + :avatar (get option :avatar) :ref ref :role "option" :focused (= id focused) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 9715b7e27b..3836f109e6 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -347,6 +347,9 @@ msgstr "The %s organization has been deleted." msgid "dashboard.team-no-longer-belong-org" msgstr "This team no longer belongs to the organization %s" +msgid "dashboard.team-belong-org" +msgstr "This team now belongs to %s" + #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Add file" @@ -1135,6 +1138,21 @@ msgstr "Team organization" msgid "dashboard.team-organization.none" msgstr "This team is not part of any organization" +msgid "dashboard.team-organization.add" +msgstr "Add to an organization" + +msgid "dashboard.select-org-modal.title" +msgstr "Add team to an organization" + +msgid "dashboard.select-org-modal.choose" +msgstr "Choose an organization:" + +msgid "dashboard.select-org-modal.select" +msgstr "Select an organization" + +msgid "dashboard.select-org-modal.accept" +msgstr "ADD TO ORGANIZATION" + msgid "dashboard.team-organization.change" msgstr "Change team organization" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 09779522cc..c150221253 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -356,6 +356,9 @@ msgstr "La organización %s se ha borrado." msgid "dashboard.team-no-longer-belong-org" msgstr "Este equipo ya no pertenece a la organización %s" +msgid "dashboard.team-belong-org" +msgstr "Este equipo ahora pertenece a la organización %s" + #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Añadir archivo" @@ -1139,6 +1142,21 @@ msgstr "Organización del equipo" msgid "dashboard.team-organization.none" msgstr "Este equipo no pertenece a ninguna organización" +msgid "dashboard.team-organization.add" +msgstr "Añadir a una organización" + +msgid "dashboard.select-org-modal.title" +msgstr "Añadir el equipo a una organización" + +msgid "dashboard.select-org-modal.choose" +msgstr "Elige una organización:" + +msgid "dashboard.select-org-modal.select" +msgstr "Elige una organización" + +msgid "dashboard.select-org-modal.accept" +msgstr "AÑADIR A UNA ORGANIZACIÓN" + msgid "dashboard.team-organization.change" msgstr "Cambiar el equipo de organización"