mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
✨ Add team to a nitrate organization
This commit is contained in:
parent
32d9688c3c
commit
a206d57443
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -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))))))))
|
||||
@ -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)})
|
||||
|
||||
|
||||
@ -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])])))
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -64,6 +64,11 @@
|
||||
color: var(--combobox-icon-color);
|
||||
}
|
||||
|
||||
.header-avatar {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.input {
|
||||
all: unset;
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user