Add team to a nitrate organization

This commit is contained in:
Pablo Alba 2026-04-15 22:31:44 +02:00 committed by Pablo Alba
parent 32d9688c3c
commit a206d57443
20 changed files with 414 additions and 78 deletions

View File

@ -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

View File

@ -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))
(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)

View File

@ -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]

View File

@ -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)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -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))))))))
(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))))))))

View File

@ -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)})

View File

@ -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])])))

View File

@ -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;

View File

@ -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

View File

@ -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)}

View File

@ -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);
}

View File

@ -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

View File

@ -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

View File

@ -64,6 +64,11 @@
color: var(--combobox-icon-color);
}
.header-avatar {
grid-template-columns: auto 1fr;
gap: var(--sp-s);
}
.input {
all: unset;

View File

@ -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]

View File

@ -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;

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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"