mirror of
https://github.com/penpot/penpot.git
synced 2025-06-02 06:01:39 +02:00
1132 lines
42 KiB
Clojure
1132 lines
42 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.main.ui.dashboard.team
|
|
(:require-macros [app.main.style :as stl])
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.schema :as sm]
|
|
[app.config :as cfg]
|
|
[app.main.data.dashboard :as dd]
|
|
[app.main.data.events :as ev]
|
|
[app.main.data.modal :as modal]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.users :as du]
|
|
[app.main.refs :as refs]
|
|
[app.main.store :as st]
|
|
[app.main.ui.components.dropdown :refer [dropdown]]
|
|
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
|
[app.main.ui.components.forms :as fm]
|
|
[app.main.ui.dashboard.change-owner]
|
|
[app.main.ui.dashboard.team-form]
|
|
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
|
|
[app.main.ui.icons :as i]
|
|
[app.main.ui.notifications.badge :refer [badge-notification]]
|
|
[app.main.ui.notifications.context-notification :refer [context-notification]]
|
|
[app.util.dom :as dom]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[beicon.v2.core :as rx]
|
|
[cuerdas.core :as str]
|
|
[rumext.v2 :as mf]))
|
|
|
|
(def ^:private arrow-icon
|
|
(i/icon-xref :arrow (stl/css :arrow-icon)))
|
|
|
|
(def ^:private menu-icon
|
|
(i/icon-xref :menu (stl/css :menu-icon)))
|
|
|
|
(def ^:private warning-icon
|
|
(i/icon-xref :msg-warning (stl/css :warning-icon)))
|
|
|
|
(def ^:private success-icon
|
|
(i/icon-xref :msg-success (stl/css :success-icon)))
|
|
|
|
(def ^:private image-icon
|
|
(i/icon-xref :img (stl/css :image-icon)))
|
|
|
|
(def ^:private user-icon
|
|
(i/icon-xref :user (stl/css :user-icon)))
|
|
|
|
(def ^:private document-icon
|
|
(i/icon-xref :document (stl/css :document-icon)))
|
|
|
|
(def ^:private group-icon
|
|
(i/icon-xref :group (stl/css :group-icon)))
|
|
|
|
(mf/defc header
|
|
{::mf/wrap [mf/memo]
|
|
::mf/wrap-props false}
|
|
[{:keys [section team invite-email]}]
|
|
(let [on-nav-members (mf/use-fn #(st/emit! (dd/go-to-team-members)))
|
|
on-nav-settings (mf/use-fn #(st/emit! (dd/go-to-team-settings)))
|
|
on-nav-invitations (mf/use-fn #(st/emit! (dd/go-to-team-invitations)))
|
|
on-nav-webhooks (mf/use-fn #(st/emit! (dd/go-to-team-webhooks)))
|
|
|
|
members-section? (= section :dashboard-team-members)
|
|
settings-section? (= section :dashboard-team-settings)
|
|
invitations-section? (= section :dashboard-team-invitations)
|
|
webhooks-section? (= section :dashboard-team-webhooks)
|
|
permissions (:permissions team)
|
|
|
|
on-invite-member
|
|
(mf/use-fn
|
|
(mf/deps team)
|
|
(fn []
|
|
(st/emit! (modal/show {:type :invite-members
|
|
:team team
|
|
:origin :team
|
|
:invite-email invite-email}))))]
|
|
|
|
(mf/with-effect []
|
|
(when invite-email
|
|
(on-invite-member)))
|
|
|
|
[:header {:class (stl/css :dashboard-header :team) :data-testid "dashboard-header"}
|
|
[:div {:class (stl/css :dashboard-title)}
|
|
[:h1 (cond
|
|
members-section? (tr "labels.members")
|
|
settings-section? (tr "labels.settings")
|
|
invitations-section? (tr "labels.invitations")
|
|
webhooks-section? (tr "labels.webhooks")
|
|
:else nil)]]
|
|
[:nav {:class (stl/css :dashboard-header-menu)}
|
|
[:ul {:class (stl/css :dashboard-header-options)}
|
|
[:li {:class (when members-section? (stl/css :active))}
|
|
[:a {:on-click on-nav-members} (tr "labels.members")]]
|
|
[:li {:class (when invitations-section? (stl/css :active))}
|
|
[:a {:on-click on-nav-invitations} (tr "labels.invitations")]]
|
|
(when (contains? cfg/flags :webhooks)
|
|
[:li {:class (when webhooks-section? (stl/css :active))}
|
|
[:a {:on-click on-nav-webhooks} (tr "labels.webhooks")]])
|
|
[:li {:class (when settings-section? (stl/css :active))}
|
|
[:a {:on-click on-nav-settings} (tr "labels.settings")]]]]
|
|
[:div {:class (stl/css :dashboard-buttons)}
|
|
(if (and (or invitations-section? members-section?) (:is-admin permissions))
|
|
[:a
|
|
{:class (stl/css :btn-secondary :btn-small)
|
|
:on-click on-invite-member
|
|
:data-testid "invite-member"}
|
|
(tr "dashboard.invite-profile")]
|
|
[:div {:class (stl/css :blank-space)}])]]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; INVITATIONS MODAL
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn get-available-roles
|
|
[permissions]
|
|
(->> [{:value "viewer" :label (tr "labels.viewer")}
|
|
{:value "editor" :label (tr "labels.editor")}
|
|
(when (:is-admin permissions)
|
|
{:value "admin" :label (tr "labels.admin")})]
|
|
(filterv identity)))
|
|
|
|
(def ^:private schema:invite-member-form
|
|
[:map {:title "InviteMemberForm"}
|
|
[:role :keyword]
|
|
[:emails [::sm/set {:min 1} ::sm/email]]
|
|
[:team-id ::sm/uuid]])
|
|
|
|
(mf/defc invite-members-modal
|
|
{::mf/register modal/components
|
|
::mf/register-as :invite-members
|
|
::mf/wrap-props false}
|
|
[{:keys [team origin invite-email]}]
|
|
(let [members-map (mf/deref refs/dashboard-team-members)
|
|
perms (:permissions team)
|
|
|
|
roles (mf/with-memo [perms]
|
|
(get-available-roles perms))
|
|
team-id (:id team)
|
|
|
|
initial (mf/with-memo [team-id]
|
|
{:role "editor" :team-id team-id})
|
|
|
|
form (fm/use-form :schema schema:invite-member-form
|
|
:initial initial)
|
|
error-text (mf/use-state "")
|
|
|
|
current-data-emails (into #{} (dm/get-in @form [:clean-data :emails]))
|
|
current-members-emails (into #{} (map (comp :email second)) members-map)
|
|
|
|
on-success
|
|
(fn [_form {:keys [total]}]
|
|
(when (pos? total)
|
|
(st/emit! (ntf/success (tr "notifications.invitation-email-sent"))))
|
|
|
|
(st/emit! (modal/hide)
|
|
(dd/fetch-team-invitations)))
|
|
|
|
on-error
|
|
(fn [_form cause]
|
|
(let [{:keys [type code] :as error} (ex-data cause)]
|
|
(cond
|
|
(and (= :validation type)
|
|
(= :profile-is-muted code))
|
|
(st/emit! (ntf/error (tr "errors.profile-is-muted"))
|
|
(modal/hide))
|
|
|
|
(and (= :validation type)
|
|
(= :max-invitations-by-request code))
|
|
(swap! error-text (tr "errors.maximum-invitations-by-request-reached" (:threshold error)))
|
|
|
|
(and (= :restriction type)
|
|
(= :max-quote-reached code))
|
|
(swap! error-text (tr "errors.max-quote-reached" (:target error)))
|
|
|
|
(or (= :member-is-muted code)
|
|
(= :email-has-permanent-bounces code)
|
|
(= :email-has-complaints code))
|
|
(swap! error-text (tr "errors.email-spam-or-permanent-bounces" (:email error)))
|
|
|
|
:else
|
|
(st/emit! (ntf/error (tr "errors.generic"))
|
|
(modal/hide)))))
|
|
|
|
on-submit
|
|
(fn [form]
|
|
(let [params (:clean-data @form)
|
|
mdata {:on-success (partial on-success form)
|
|
:on-error (partial on-error form)}]
|
|
(st/emit! (-> (dd/invite-team-members (with-meta params mdata))
|
|
(with-meta {::ev/origin origin}))
|
|
(dd/fetch-team-invitations)
|
|
(dd/fetch-team-members (:id team)))))]
|
|
|
|
|
|
[:div {:class (stl/css-case :modal-team-container true
|
|
:hero (= origin :hero))}
|
|
[:& fm/form {:on-submit on-submit :form form}
|
|
[:div {:class (stl/css :modal-title)}
|
|
(tr "modals.invite-team-member.title")]
|
|
|
|
(when-not (= "" @error-text)
|
|
[:& context-notification {:content @error-text
|
|
:level :error}])
|
|
|
|
(when (some current-data-emails current-members-emails)
|
|
[:& context-notification {:content (tr "modals.invite-member.repeated-invitation")
|
|
:level :warning}])
|
|
|
|
[:div {:class (stl/css :role-select)}
|
|
[:p {:class (stl/css :role-title)}
|
|
(tr "onboarding.choice.team-up.roles")]
|
|
[:& fm/select {:name :role :options roles}]]
|
|
|
|
[:div {:class (stl/css :invitation-row)}
|
|
[:& fm/multi-input {:type "email"
|
|
:class (stl/css :email-input)
|
|
:name :emails
|
|
:auto-focus? true
|
|
:trim true
|
|
:valid-item-fn sm/parse-email
|
|
:caution-item-fn current-members-emails
|
|
:label (tr "modals.invite-member.emails")
|
|
:invite-email invite-email}]]
|
|
|
|
[:div {:class (stl/css :action-buttons)}
|
|
[:> fm/submit-button*
|
|
{:label (tr "modals.invite-member-confirm.accept")
|
|
:class (stl/css :accept-btn)
|
|
:disabled (and (boolean (some current-data-emails current-members-emails))
|
|
(empty? (remove current-members-emails current-data-emails)))}]]]]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; MEMBERS SECTION
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(mf/defc member-info
|
|
{::mf/wrap-props false}
|
|
[{:keys [member profile]}]
|
|
(let [is-you? (= (:id profile) (:id member))]
|
|
[:*
|
|
[:img {:class (stl/css :member-image)
|
|
:src (cfg/resolve-profile-photo-url member)}]
|
|
[:div {:class (stl/css :member-info)}
|
|
[:div {:class (stl/css :member-name)} (:name member)
|
|
(when is-you?
|
|
[:span {:class (stl/css :you)} (tr "labels.you")])]
|
|
[:div {:class (stl/css :member-email)} (:email member)]]]))
|
|
|
|
(mf/defc rol-info
|
|
{::mf/wrap-props false}
|
|
[{:keys [member team on-set-admin on-set-editor on-set-owner on-set-viewer profile]}]
|
|
(let [member-is-owner (:is-owner member)
|
|
member-is-admin (and (:is-admin member) (not member-is-owner))
|
|
member-is-editor (and (:can-edit member) (and (not member-is-admin) (not member-is-owner)))
|
|
member-is-viewer (and (not member-is-editor) (not member-is-admin) (not member-is-owner))
|
|
show? (mf/use-state false)
|
|
|
|
permissions (:permissions team)
|
|
is-owner (:is-owner permissions)
|
|
is-admin (:is-admin permissions)
|
|
|
|
is-you (= (:id profile) (:id member))
|
|
|
|
can-change-rol (or is-owner is-admin)
|
|
not-superior (or is-admin (and can-change-rol (or member-is-admin member-is-editor member-is-viewer)))
|
|
|
|
role (cond
|
|
member-is-owner "labels.owner"
|
|
member-is-admin "labels.admin"
|
|
member-is-editor "labels.editor"
|
|
:else "labels.viewer")
|
|
|
|
on-show (mf/use-fn #(reset! show? true))
|
|
on-hide (mf/use-fn #(reset! show? false))]
|
|
[:*
|
|
(if (and can-change-rol not-superior (not (and is-you is-owner)))
|
|
[:div {:class (stl/css :rol-selector :has-priv)
|
|
:on-click on-show}
|
|
[:span {:class (stl/css :rol-label)} (tr role)]
|
|
arrow-icon]
|
|
[:div {:class (stl/css :rol-selector)}
|
|
[:span {:class (stl/css :rol-label)} (tr role)]])
|
|
|
|
[:& dropdown {:show @show? :on-close on-hide}
|
|
[:ul {:class (stl/css :roles-dropdown)}
|
|
[:li {:on-click on-set-admin
|
|
:class (stl/css :rol-dropdown-item)}
|
|
(tr "labels.admin")]
|
|
[:li {:on-click on-set-editor
|
|
:class (stl/css :rol-dropdown-item)}
|
|
(tr "labels.editor")]
|
|
[:li {:on-click on-set-viewer
|
|
:class (stl/css :rol-dropdown-item)}
|
|
(tr "labels.viewer")]
|
|
(when is-owner
|
|
[:li {:on-click (partial on-set-owner member)
|
|
:class (stl/css :rol-dropdown-item)}
|
|
(tr "labels.owner")])]]]))
|
|
|
|
(mf/defc member-actions
|
|
{::mf/wrap-props false}
|
|
[{:keys [member team on-delete on-leave profile]}]
|
|
(let [is-owner? (:is-owner member)
|
|
owner? (dm/get-in team [:permissions :is-owner])
|
|
admin? (dm/get-in team [:permissions :is-admin])
|
|
show? (mf/use-state false)
|
|
is-you? (= (:id profile) (:id member))
|
|
can-delete? (or owner? admin?)
|
|
|
|
on-show (mf/use-fn #(reset! show? true))
|
|
on-hide (mf/use-fn #(reset! show? false))]
|
|
|
|
|
|
(when (or is-you? (and can-delete? (not (and is-owner? (not owner?)))))
|
|
[:*
|
|
[:button {:class (stl/css :menu-btn)
|
|
:on-click on-show}
|
|
menu-icon]
|
|
|
|
[:& dropdown {:show @show? :on-close on-hide}
|
|
[:ul {:class (stl/css :actions-dropdown)}
|
|
(when is-you?
|
|
[:li {:on-click on-leave
|
|
:class (stl/css :action-dropdown-item)
|
|
:key "is-you-option"} (tr "dashboard.leave-team")])
|
|
(when (and can-delete? (not is-you?) (not (and is-owner? (not owner?))))
|
|
[:li {:on-click on-delete
|
|
:class (stl/css :action-dropdown-item)
|
|
:key "is-not-you-option"} (tr "labels.remove-member")])]]])))
|
|
|
|
(defn- set-role! [member-id role]
|
|
(let [params {:member-id member-id :role role}]
|
|
(st/emit! (dd/update-team-member-role params))))
|
|
|
|
(mf/defc team-member
|
|
{::mf/wrap [mf/memo]
|
|
::mf/wrap-props false}
|
|
[{:keys [team member members profile]}]
|
|
|
|
(let [member-id (:id member)
|
|
on-set-admin (mf/use-fn (mf/deps member-id) (partial set-role! member-id :admin))
|
|
on-set-editor (mf/use-fn (mf/deps member-id) (partial set-role! member-id :editor))
|
|
on-set-viewer (mf/use-fn (mf/deps member-id) (partial set-role! member-id :viewer))
|
|
owner? (dm/get-in team [:permissions :is-owner])
|
|
|
|
on-set-owner
|
|
(mf/use-fn
|
|
(mf/deps member)
|
|
(fn [member _event]
|
|
(let [params {:type :confirm
|
|
:title (tr "modals.promote-owner-confirm.title")
|
|
:message (tr "modals.promote-owner-confirm.message" (:name member))
|
|
:scd-message (tr "modals.promote-owner-confirm.hint")
|
|
:accept-label (tr "modals.promote-owner-confirm.accept")
|
|
:on-accept (partial set-role! member-id :owner)
|
|
:accept-style :primary}]
|
|
(st/emit! (modal/show params)))))
|
|
|
|
on-success
|
|
(mf/use-fn
|
|
(mf/deps profile)
|
|
(fn []
|
|
(st/emit! (dd/go-to-projects (:default-team-id profile))
|
|
(modal/hide)
|
|
(du/fetch-teams))))
|
|
|
|
on-error
|
|
(mf/use-fn
|
|
(fn [{:keys [code] :as error}]
|
|
(condp = code
|
|
|
|
:no-enough-members-for-leave
|
|
(rx/of (ntf/error (tr "errors.team-leave.insufficient-members")))
|
|
|
|
:member-does-not-exist
|
|
(rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists")))
|
|
|
|
:owner-cant-leave-team
|
|
(rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave")))
|
|
|
|
(rx/throw error))))
|
|
|
|
on-delete-accepted
|
|
(mf/use-fn
|
|
(mf/deps team on-success on-error)
|
|
(fn []
|
|
(st/emit! (dd/delete-team (with-meta team {:on-success on-success
|
|
:on-error on-error})))))
|
|
|
|
on-leave-accepted
|
|
(mf/use-fn
|
|
(mf/deps on-success on-error)
|
|
(fn [member-id]
|
|
(let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))]
|
|
(st/emit! (dd/leave-team (with-meta params
|
|
{:on-success on-success
|
|
:on-error on-error}))))))
|
|
|
|
on-leave-and-close
|
|
(mf/use-fn
|
|
(mf/deps on-delete-accepted)
|
|
(fn []
|
|
(st/emit! (modal/show
|
|
{:type :confirm
|
|
:title (tr "modals.leave-confirm.title")
|
|
:message (tr "modals.leave-and-close-confirm.message" (:name team))
|
|
:scd-message (tr "modals.leave-and-close-confirm.hint")
|
|
:accept-label (tr "modals.leave-confirm.accept")
|
|
:on-accept on-delete-accepted}))))
|
|
|
|
on-change-owner-and-leave
|
|
(mf/use-fn
|
|
(mf/deps profile team on-leave-accepted)
|
|
(fn []
|
|
(st/emit! (dd/fetch-team-members (:id team))
|
|
(modal/show
|
|
{:type :leave-and-reassign
|
|
:profile profile
|
|
:team team
|
|
:accept on-leave-accepted}))))
|
|
|
|
on-leave
|
|
(mf/use-fn
|
|
(mf/deps on-leave-accepted)
|
|
(fn []
|
|
(st/emit! (modal/show
|
|
{:type :confirm
|
|
:title (tr "modals.leave-confirm.title")
|
|
:message (tr "modals.leave-confirm.message")
|
|
:accept-label (tr "modals.leave-confirm.accept")
|
|
:on-accept on-leave-accepted}))))
|
|
|
|
on-delete
|
|
(mf/use-fn
|
|
(mf/deps member-id)
|
|
(fn []
|
|
(let [on-accept #(st/emit! (dd/delete-team-member {:member-id member-id}))
|
|
params {:type :confirm
|
|
:title (tr "modals.delete-team-member-confirm.title")
|
|
:message (tr "modals.delete-team-member-confirm.message")
|
|
:accept-label (tr "modals.delete-team-member-confirm.accept")
|
|
:on-accept on-accept}]
|
|
(st/emit! (modal/show params)))))
|
|
|
|
on-leave'
|
|
(cond (= 1 (count members)) on-leave-and-close
|
|
(= true owner?) on-change-owner-and-leave
|
|
:else on-leave)]
|
|
|
|
[:div {:class (stl/css :table-row)}
|
|
[:div {:class (stl/css :table-field :field-name)}
|
|
[:& member-info {:member member :profile profile}]]
|
|
|
|
[:div {:class (stl/css :table-field :field-roles)}
|
|
[:& rol-info {:member member
|
|
:team team
|
|
:on-set-admin on-set-admin
|
|
:on-set-editor on-set-editor
|
|
:on-set-viewer on-set-viewer
|
|
:on-set-owner on-set-owner
|
|
:profile profile}]]
|
|
|
|
[:div {:class (stl/css :table-field :field-actions)}
|
|
[:& member-actions {:member member
|
|
:profile profile
|
|
:team team
|
|
:on-delete on-delete
|
|
:on-leave on-leave'}]]]))
|
|
|
|
(mf/defc team-members
|
|
{::mf/wrap-props false}
|
|
[{:keys [members-map team profile]}]
|
|
(let [members (mf/with-memo [members-map]
|
|
(->> (vals members-map)
|
|
(sort-by :created-at)
|
|
(remove :is-owner)))
|
|
owner (mf/with-memo [members-map]
|
|
(->> (vals members-map)
|
|
(d/seek :is-owner)))]
|
|
|
|
[:div {:class (stl/css :dashboard-table :team-members)}
|
|
[:div {:class (stl/css :table-header)}
|
|
[:div {:class (stl/css :table-field :title-field-name)} (tr "labels.member")]
|
|
[:div {:class (stl/css :table-field :title-field-role)} (tr "labels.role")]]
|
|
|
|
[:div {:class (stl/css :table-rows)}
|
|
[:& team-member
|
|
{:member owner
|
|
:team team
|
|
:profile profile
|
|
:members members-map}]
|
|
|
|
(for [item members]
|
|
[:& team-member
|
|
{:member item
|
|
:team team
|
|
:profile profile
|
|
:key (:id item)
|
|
:members members-map}])]]))
|
|
|
|
(mf/defc team-members-page
|
|
{::mf/wrap-props false}
|
|
[{:keys [team profile invite-email]}]
|
|
(let [members-map (mf/deref refs/dashboard-team-members)]
|
|
|
|
(mf/with-effect [team]
|
|
(dom/set-html-title
|
|
(tr "title.team-members"
|
|
(if (:is-default team)
|
|
(tr "dashboard.your-penpot")
|
|
(:name team)))))
|
|
|
|
(mf/with-effect [team]
|
|
(st/emit! (dd/fetch-team-members (:id team))))
|
|
|
|
[:*
|
|
[:& header {:section :dashboard-team-members :team team :invite-email invite-email}]
|
|
[:section {:class (stl/css :dashboard-container :dashboard-team-members)}
|
|
[:& team-members
|
|
{:profile profile
|
|
:team team
|
|
:members-map members-map}]]]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; INVITATIONS SECTION
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(mf/defc invitation-role-selector
|
|
{::mf/wrap-props false}
|
|
[{:keys [can-invite? role status on-change]}]
|
|
(let [show? (mf/use-state false)
|
|
label (cond
|
|
(= role :owner) (tr "labels.owner")
|
|
(= role :admin) (tr "labels.admin")
|
|
(= role :editor) (tr "labels.editor")
|
|
:else (tr "labels.viewer"))
|
|
|
|
on-hide (mf/use-fn #(reset! show? false))
|
|
on-show (mf/use-fn #(reset! show? true))
|
|
|
|
on-change'
|
|
(mf/use-fn
|
|
(mf/deps on-change)
|
|
(fn [event]
|
|
(let [role (-> (dom/get-current-target event)
|
|
(dom/get-data "role")
|
|
(keyword))]
|
|
(on-change role event))))]
|
|
|
|
[:*
|
|
(if (and can-invite? (= status :pending))
|
|
[:div {:class (stl/css :rol-selector :has-priv)
|
|
:on-click on-show}
|
|
[:span {:class (stl/css :rol-label)} label]
|
|
arrow-icon]
|
|
[:div {:class (stl/css :rol-selector)}
|
|
[:span {:class (stl/css :rol-label)} label]])
|
|
|
|
[:& dropdown {:show @show? :on-close on-hide}
|
|
[:ul {:class (stl/css :roles-dropdown)}
|
|
[:li {:data-role "admin"
|
|
:class (stl/css :rol-dropdown-item)
|
|
:on-click on-change'}
|
|
(tr "labels.admin")]
|
|
[:li {:data-role "editor"
|
|
:class (stl/css :rol-dropdown-item)
|
|
:on-click on-change'}
|
|
(tr "labels.editor")]
|
|
[:li {:data-role "viewer"
|
|
:class (stl/css :rol-dropdown-item)
|
|
:on-click on-change'}
|
|
(tr "labels.viewer")]]]]))
|
|
|
|
(mf/defc invitation-actions
|
|
{::mf/wrap-props false}
|
|
[{:keys [invitation team-id]}]
|
|
(let [show? (mf/use-state false)
|
|
|
|
email (:email invitation)
|
|
role (:role invitation)
|
|
|
|
on-error
|
|
(mf/use-fn
|
|
(mf/deps email)
|
|
(fn [cause]
|
|
(let [{:keys [type code] :as error} (ex-data cause)]
|
|
(cond
|
|
(and (= :validation type)
|
|
(= :profile-is-muted code))
|
|
(rx/of (ntf/error (tr "errors.profile-is-muted")))
|
|
|
|
(and (= :validation type)
|
|
(= :member-is-muted code))
|
|
(rx/of (ntf/error (tr "errors.member-is-muted")))
|
|
|
|
(and (= :restriction type)
|
|
(or (= :email-has-permanent-bounces code)
|
|
(= :email-has-complaints code)))
|
|
(rx/of (ntf/error (tr "errors.email-has-permanent-bounces" email)))
|
|
|
|
:else
|
|
(rx/throw cause)))))
|
|
|
|
on-delete
|
|
(mf/use-fn
|
|
(mf/deps email team-id)
|
|
(fn []
|
|
(let [params {:email email :team-id team-id}
|
|
mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}]
|
|
(st/emit! (dd/delete-team-invitation (with-meta params mdata))))))
|
|
|
|
on-resend-success
|
|
(mf/use-fn
|
|
(fn []
|
|
(st/emit! (ntf/success (tr "notifications.invitation-email-sent"))
|
|
(modal/hide)
|
|
(dd/fetch-team-invitations))))
|
|
|
|
on-resend
|
|
(mf/use-fn
|
|
(mf/deps email team-id)
|
|
(fn []
|
|
(let [params (with-meta {:emails #{email}
|
|
:team-id team-id
|
|
:resend? true
|
|
:role role}
|
|
{:on-success on-resend-success
|
|
:on-error on-error})]
|
|
(st/emit!
|
|
(-> (dd/invite-team-members params)
|
|
(with-meta {::ev/origin :team}))))))
|
|
|
|
on-copy-success
|
|
(mf/use-fn
|
|
(fn []
|
|
(st/emit! (ntf/success (tr "notifications.invitation-link-copied"))
|
|
(modal/hide))))
|
|
|
|
on-copy
|
|
(mf/use-fn
|
|
(mf/deps email team-id)
|
|
(fn []
|
|
(let [params (with-meta {:email email :team-id team-id}
|
|
{:on-success on-copy-success
|
|
:on-error on-error})]
|
|
(st/emit!
|
|
(-> (dd/copy-invitation-link params)
|
|
(with-meta {::ev/origin :team}))))))
|
|
|
|
on-hide (mf/use-fn #(reset! show? false))
|
|
on-show (mf/use-fn #(reset! show? true))]
|
|
|
|
[:*
|
|
[:button {:class (stl/css :menu-btn)
|
|
:on-click on-show}
|
|
menu-icon]
|
|
|
|
[:& dropdown {:show @show? :on-close on-hide}
|
|
[:ul {:class (stl/css :actions-dropdown :invitations-dropdown)}
|
|
[:li {:on-click on-copy
|
|
:class (stl/css :action-dropdown-item)}
|
|
(tr "labels.copy-invitation-link")]
|
|
[:li {:on-click on-resend
|
|
:class (stl/css :action-dropdown-item)}
|
|
(tr "labels.resend-invitation")]
|
|
[:li {:on-click on-delete
|
|
:class (stl/css :action-dropdown-item)}
|
|
(tr "labels.delete-invitation")]]]]))
|
|
|
|
(mf/defc invitation-row
|
|
{::mf/wrap [mf/memo]
|
|
::mf/wrap-props false}
|
|
[{:keys [invitation can-invite? team-id] :as props}]
|
|
|
|
(let [expired? (:expired invitation)
|
|
email (:email invitation)
|
|
role (:role invitation)
|
|
status (if expired? :expired :pending)
|
|
type (if expired? :warning :default)
|
|
badge-content (if (= status :expired)
|
|
(tr "labels.expired-invitation")
|
|
(tr "labels.pending-invitation"))
|
|
|
|
on-change-role
|
|
(mf/use-fn
|
|
(mf/deps email team-id)
|
|
(fn [role _event]
|
|
(let [params {:email email :team-id team-id :role role}
|
|
mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}]
|
|
(st/emit! (dd/update-team-invitation-role (with-meta params mdata))))))]
|
|
|
|
[:div {:class (stl/css :table-row :table-row-invitations)}
|
|
[:div {:class (stl/css :table-field :field-email)} email]
|
|
|
|
[:div {:class (stl/css :table-field :field-roles)}
|
|
[:& invitation-role-selector
|
|
{:can-invite? can-invite?
|
|
:role role
|
|
:status status
|
|
:on-change on-change-role}]]
|
|
|
|
[:div {:class (stl/css :table-field :field-status)}
|
|
[:& badge-notification {:type type :content badge-content}]]
|
|
|
|
[:div {:class (stl/css :table-field :field-actions)}
|
|
(when can-invite?
|
|
[:& invitation-actions
|
|
{:invitation invitation
|
|
:team-id team-id}])]]))
|
|
|
|
(mf/defc empty-invitation-table
|
|
[{:keys [can-invite?] :as props}]
|
|
[:div {:class (stl/css :empty-invitations)}
|
|
[:span (tr "labels.no-invitations")]
|
|
(when can-invite?
|
|
[:> i18n/tr-html* {:content (tr "labels.no-invitations-hint")
|
|
:tag-name "span"}])])
|
|
|
|
(mf/defc invitation-section
|
|
[{:keys [team invitations] :as props}]
|
|
(let [owner? (dm/get-in team [:permissions :is-owner])
|
|
admin? (dm/get-in team [:permissions :is-admin])
|
|
can-invite? (or owner? admin?)
|
|
team-id (:id team)]
|
|
|
|
[:div {:class (stl/css :invitations)}
|
|
[:div {:class (stl/css :table-header)}
|
|
[:div {:class (stl/css :title-field-name)} (tr "labels.invitations")]
|
|
[:div {:class (stl/css :title-field-role)} (tr "labels.role")]
|
|
[:div {:class (stl/css :title-field-status)} (tr "labels.status")]]
|
|
(if (empty? invitations)
|
|
[:& empty-invitation-table {:can-invite? can-invite?}]
|
|
[:div {:class (stl/css :table-rows)}
|
|
(for [invitation invitations]
|
|
[:& invitation-row
|
|
{:key (:email invitation)
|
|
:invitation invitation
|
|
:can-invite? can-invite?
|
|
:team-id team-id}])])]))
|
|
|
|
(mf/defc team-invitations-page
|
|
[{:keys [team] :as props}]
|
|
(let [invitations (mf/deref refs/dashboard-team-invitations)]
|
|
|
|
(mf/with-effect [team]
|
|
(dom/set-html-title
|
|
(tr "title.team-invitations"
|
|
(if (:is-default team)
|
|
(tr "dashboard.your-penpot")
|
|
(:name team)))))
|
|
|
|
(mf/with-effect []
|
|
(st/emit! (dd/fetch-team-invitations)))
|
|
|
|
[:*
|
|
[:& header {:section :dashboard-team-invitations
|
|
:team team}]
|
|
[:section {:class (stl/css :dashboard-team-invitations)}
|
|
;; TODO: We should consider adding a "loading state" here
|
|
;; with an (if (nil? invitations) [:& loading-state] [:& invitations])
|
|
(when-not (nil? invitations)
|
|
[:& invitation-section {:team team
|
|
:invitations invitations}])]]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; WEBHOOKS SECTION
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private schema:webhook-form
|
|
[:map {:title "WebhookForm"}
|
|
[:uri [::sm/uri {:max 4069 :prefix #"^http[s]?://"
|
|
:error/code "errors.webhooks.invalid-uri"}]]
|
|
[:mtype ::sm/text]])
|
|
|
|
(def valid-webhook-mtypes
|
|
[{:label "application/json" :value "application/json"}
|
|
{:label "application/transit+json" :value "application/transit+json"}])
|
|
|
|
(defn- extract-status
|
|
[error-code]
|
|
(-> error-code (str/split #":") second))
|
|
|
|
(mf/defc webhook-modal
|
|
{::mf/register modal/components
|
|
::mf/register-as :webhook}
|
|
[{:keys [webhook] :as props}]
|
|
|
|
(let [initial (mf/with-memo []
|
|
(or (some-> webhook (update :uri str))
|
|
{:is-active false :mtype "application/json"}))
|
|
form (fm/use-form :schema schema:webhook-form
|
|
:initial initial)
|
|
on-success
|
|
(mf/use-fn
|
|
(fn [_]
|
|
(let [message (tr "dashboard.webhooks.create.success")]
|
|
(st/emit! (dd/fetch-team-webhooks)
|
|
(ntf/success message)
|
|
(modal/hide)))))
|
|
|
|
on-error
|
|
(mf/use-fn
|
|
(fn [form error]
|
|
(let [{:keys [type code hint]} (ex-data error)]
|
|
(if (and (= type :validation)
|
|
(= code :webhook-validation))
|
|
(let [message (cond
|
|
(= hint "unknown")
|
|
(tr "errors.webhooks.unexpected")
|
|
(= hint "invalid-uri")
|
|
(tr "errors.webhooks.invalid-uri")
|
|
(= hint "ssl-validation-error")
|
|
(tr "errors.webhooks.ssl-validation")
|
|
(= hint "timeout")
|
|
(tr "errors.webhooks.timeout")
|
|
(= hint "connection-error")
|
|
(tr "errors.webhooks.connection")
|
|
(str/starts-with? hint "unexpected-status")
|
|
(tr "errors.webhooks.unexpected-status" (extract-status hint)))]
|
|
(swap! form assoc-in [:errors :uri] {:message message}))
|
|
(rx/throw error)))))
|
|
|
|
on-create-submit
|
|
(mf/use-fn
|
|
(fn [form]
|
|
(let [cdata (:clean-data @form)
|
|
mdata {:on-success (partial on-success form)
|
|
:on-error (partial on-error form)}
|
|
params {:uri (:uri cdata)
|
|
:mtype (:mtype cdata)
|
|
:is-active (:is-active cdata)}]
|
|
(st/emit! (dd/create-team-webhook
|
|
(with-meta params mdata))))))
|
|
|
|
on-update-submit
|
|
(mf/use-fn
|
|
(fn [form]
|
|
(let [params (:clean-data @form)
|
|
mdata {:on-success (partial on-success form)
|
|
:on-error (partial on-error form)}]
|
|
(st/emit! (dd/update-team-webhook
|
|
(with-meta params mdata))))))
|
|
|
|
on-submit
|
|
(mf/use-fn
|
|
(fn [form]
|
|
(let [data (:clean-data @form)]
|
|
(if (:id data)
|
|
(on-update-submit form)
|
|
(on-create-submit form)))))
|
|
|
|
on-modal-close #(st/emit! (modal/hide))]
|
|
[:div {:class (stl/css :modal-overlay)}
|
|
[:div {:class (stl/css :modal-container)}
|
|
[:& fm/form {:form form :on-submit on-submit}
|
|
[:div {:class (stl/css :modal-header)}
|
|
(if webhook
|
|
[:h2 {:class (stl/css :modal-title)} (tr "modals.edit-webhook.title")]
|
|
[:h2 {:class (stl/css :modal-title)} (tr "modals.create-webhook.title")])
|
|
|
|
[:button {:class (stl/css :modal-close-btn)
|
|
:on-click on-modal-close} i/close]]
|
|
|
|
[:div {:class (stl/css :modal-content)}
|
|
[:div {:class (stl/css :fields-row)}
|
|
[:& fm/input {:type "text"
|
|
:auto-focus? true
|
|
:form form
|
|
:name :uri
|
|
:label (tr "modals.create-webhook.url.label")
|
|
:placeholder (tr "modals.create-webhook.url.placeholder")}]]
|
|
[:div {:class (stl/css :fields-row)}
|
|
[:div {:class (stl/css :select-title)} (tr "dashboard.webhooks.content-type")]
|
|
[:& fm/select {:options valid-webhook-mtypes
|
|
:default "application/json"
|
|
:name :mtype}]]
|
|
[:div {:class (stl/css :fields-row)}
|
|
[:& fm/input {:type "checkbox"
|
|
:class (stl/css :custom-input-checkbox)
|
|
:form form
|
|
:name :is-active
|
|
:label (tr "dashboard.webhooks.active")}]
|
|
[:div {:class (stl/css :hint)} (tr "dashboard.webhooks.active.explain")]]]
|
|
|
|
[:div {:class (stl/css :modal-footer)}
|
|
[:div {:class (stl/css :action-buttons)}
|
|
[:input {:class (stl/css :cancel-button)
|
|
:type "button"
|
|
:value (tr "labels.cancel")
|
|
:on-click #(modal/hide!)}]
|
|
[:> fm/submit-button*
|
|
{:label (if webhook
|
|
(tr "modals.edit-webhook.submit-label")
|
|
(tr "modals.create-webhook.submit-label"))}]]]]]]))
|
|
|
|
(mf/defc webhooks-hero
|
|
{::mf/wrap-props false}
|
|
[]
|
|
[:div {:class (stl/css :webhooks-hero-container)}
|
|
[:h2 {:class (stl/css :hero-title)}
|
|
(tr "labels.webhooks")]
|
|
[:> i18n/tr-html* {:class (stl/css :hero-desc)
|
|
:content (tr "dashboard.webhooks.description")}]
|
|
[:button {:class (stl/css :hero-btn)
|
|
:on-click #(st/emit! (modal/show :webhook {}))}
|
|
(tr "dashboard.webhooks.create")]])
|
|
|
|
(mf/defc webhook-actions
|
|
{::mf/props :obj
|
|
::mf/private true}
|
|
[{:keys [on-edit on-delete can-edit]}]
|
|
(let [show? (mf/use-state false)
|
|
on-show (mf/use-fn #(reset! show? true))
|
|
on-hide (mf/use-fn #(reset! show? false))]
|
|
(if can-edit
|
|
[:*
|
|
[:button {:class (stl/css :menu-btn)
|
|
:on-click on-show}
|
|
menu-icon]
|
|
[:& dropdown {:show @show? :on-close on-hide}
|
|
[:ul {:class (stl/css :webhook-actions-dropdown)}
|
|
[:li {:on-click on-edit
|
|
:class (stl/css :webhook-dropdown-item)} (tr "labels.edit")]
|
|
[:li {:on-click on-delete
|
|
:class (stl/css :webhook-dropdown-item)} (tr "labels.delete")]]]]
|
|
|
|
[:span {:title (tr "dashboard.webhooks.cant-edit")
|
|
:class (stl/css :menu-disabled)}
|
|
[:> icon* {:id "menu"}]])))
|
|
|
|
(mf/defc last-delivery-icon
|
|
{::mf/wrap-props false}
|
|
[{:keys [success? text]}]
|
|
[:div {:class (stl/css :last-delivery-icon)
|
|
:title text}
|
|
(if success?
|
|
success-icon
|
|
warning-icon)])
|
|
|
|
(mf/defc webhook-item
|
|
{::mf/wrap [mf/memo]}
|
|
[{:keys [webhook permissions] :as props}]
|
|
(let [error-code (:error-code webhook)
|
|
id (:id webhook)
|
|
creator-id (:profile-id webhook)
|
|
profile (mf/deref refs/profile)
|
|
user-id (:id profile)
|
|
can-edit (or (:can-edit permissions)
|
|
(= creator-id user-id))
|
|
on-edit
|
|
(mf/use-fn
|
|
(mf/deps webhook)
|
|
(fn []
|
|
(st/emit! (modal/show :webhook {:webhook webhook}))))
|
|
|
|
on-delete-accepted
|
|
(mf/use-fn
|
|
(mf/deps id)
|
|
(fn []
|
|
(let [params {:id id}
|
|
mdata {:on-success #(st/emit! (dd/fetch-team-webhooks))}]
|
|
(st/emit! (dd/delete-team-webhook (with-meta params mdata))))))
|
|
|
|
on-delete
|
|
(mf/use-fn
|
|
(mf/deps on-delete-accepted)
|
|
(fn []
|
|
(let [params {:type :confirm
|
|
:title (tr "modals.delete-webhook.title")
|
|
:message (tr "modals.delete-webhook.message")
|
|
:accept-label (tr "modals.delete-webhook.accept")
|
|
:on-accept on-delete-accepted}]
|
|
(st/emit! (modal/show params)))))
|
|
|
|
last-delivery-text
|
|
(if (nil? error-code)
|
|
(tr "webhooks.last-delivery.success")
|
|
(dm/str (tr "errors.webhooks.last-delivery")
|
|
(cond
|
|
(= error-code "ssl-validation-error")
|
|
(dm/str " " (tr "errors.webhooks.ssl-validation"))
|
|
|
|
(str/starts-with? error-code "unexpected-status")
|
|
(dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))))))]
|
|
|
|
|
|
[:div {:class (stl/css :table-row :webhook-row)}
|
|
[:div {:class (stl/css :table-field :last-delivery)
|
|
:title last-delivery-text}
|
|
(if (nil? error-code)
|
|
success-icon
|
|
warning-icon)]
|
|
[:div {:class (stl/css :table-field :uri)}
|
|
[:div (dm/str (:uri webhook))]]
|
|
[:div {:class (stl/css :table-field :active)}
|
|
[:div (if (:is-active webhook)
|
|
(tr "labels.active")
|
|
(tr "labels.inactive"))]]
|
|
[:div {:class (stl/css :table-field :actions)}
|
|
[:& webhook-actions
|
|
{:on-edit on-edit
|
|
:on-delete on-delete
|
|
:can-edit can-edit}]]]))
|
|
|
|
(mf/defc webhooks-list
|
|
{::mf/wrap-props false}
|
|
[{:keys [webhooks permissions]}]
|
|
[:div {:class (stl/css :table-rows :webhook-table)}
|
|
(for [webhook webhooks]
|
|
[:& webhook-item {:webhook webhook :key (:id webhook) :permissions permissions}])])
|
|
|
|
(mf/defc team-webhooks-page
|
|
{::mf/wrap-props false}
|
|
[{:keys [team]}]
|
|
(let [webhooks (mf/deref refs/dashboard-team-webhooks)]
|
|
|
|
(mf/with-effect [team]
|
|
(dom/set-html-title
|
|
(tr "title.team-webhooks"
|
|
(if (:is-default team)
|
|
(tr "dashboard.your-penpot")
|
|
(:name team)))))
|
|
|
|
(mf/with-effect [team]
|
|
(st/emit! (dd/fetch-team-webhooks)))
|
|
|
|
[:*
|
|
[:& header {:team team :section :dashboard-team-webhooks}]
|
|
[:section {:class (stl/css :dashboard-container :dashboard-team-webhooks)}
|
|
[:*
|
|
[:& webhooks-hero]
|
|
(if (empty? webhooks)
|
|
[:div {:class (stl/css :webhooks-empty)}
|
|
[:div (tr "dashboard.webhooks.empty.no-webhooks")]
|
|
[:div (tr "dashboard.webhooks.empty.add-one")]]
|
|
[:& webhooks-list {:webhooks webhooks :permissions (:permissions team)}])]]]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; SETTINGS SECTION
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(mf/defc team-settings-page
|
|
{::mf/wrap-props false}
|
|
[{:keys [team]}]
|
|
(let [finput (mf/use-ref)
|
|
|
|
members-map (mf/deref refs/dashboard-team-members)
|
|
owner (->> (vals members-map)
|
|
(d/seek :is-owner))
|
|
|
|
stats (mf/deref refs/dashboard-team-stats)
|
|
|
|
permissions (:permissions team)
|
|
can-edit (or (:is-owner permissions)
|
|
(:is-admin permissions))
|
|
|
|
on-image-click
|
|
(mf/use-callback #(dom/click (mf/ref-val finput)))
|
|
|
|
on-file-selected
|
|
(fn [file]
|
|
(st/emit! (dd/update-team-photo file)))]
|
|
|
|
|
|
(mf/with-effect [team]
|
|
(dom/set-html-title (tr "title.team-settings"
|
|
(if (:is-default team)
|
|
(tr "dashboard.your-penpot")
|
|
(:name team)))))
|
|
|
|
|
|
(mf/with-effect [team]
|
|
(let [team-id (:id team)]
|
|
(st/emit! (dd/fetch-team-members team-id)
|
|
(dd/fetch-team-stats team-id))))
|
|
|
|
[:*
|
|
[:& header {:section :dashboard-team-settings :team team}]
|
|
[:section {:class (stl/css :dashboard-team-settings)}
|
|
[:div {:class (stl/css :block :info-block)}
|
|
[:div {:class (stl/css :block-label)}
|
|
(tr "dashboard.team-info")]
|
|
[:div {:class (stl/css :block-text)}
|
|
(:name team)]
|
|
[:div {:class (stl/css :team-icon)}
|
|
(when can-edit
|
|
[:button {:class (stl/css :update-overlay)
|
|
:on-click on-image-click}
|
|
image-icon])
|
|
[:img {:class (stl/css :team-image)
|
|
:src (cfg/resolve-team-photo-url team)}]
|
|
(when can-edit
|
|
[:& file-uploader {:accept "image/jpeg,image/png"
|
|
:multi false
|
|
:ref finput
|
|
:on-selected on-file-selected}])]]
|
|
|
|
[:div {:class (stl/css :block)}
|
|
[:div {:class (stl/css :block-label)}
|
|
(tr "dashboard.team-members")]
|
|
|
|
[:div {:class (stl/css :block-content)}
|
|
[:img {:class (stl/css :owner-icon)
|
|
:src (cfg/resolve-profile-photo-url owner)}]
|
|
[:span {:class (stl/css :block-text)}
|
|
(str (:name owner) " (" (tr "labels.owner") ")")]]
|
|
|
|
[:div {:class (stl/css :block-content)}
|
|
user-icon
|
|
[:span {:class (stl/css :block-text)}
|
|
(tr "dashboard.num-of-members" (count members-map))]]]
|
|
|
|
[:div {:class (stl/css :block)}
|
|
[:div {:class (stl/css :block-label)}
|
|
(tr "dashboard.team-projects")]
|
|
|
|
[:div {:class (stl/css :block-content)}
|
|
group-icon
|
|
[:span {:class (stl/css :block-text)}
|
|
(tr "labels.num-of-projects" (i18n/c (dec (:projects stats))))]]
|
|
|
|
[:div {:class (stl/css :block-content)}
|
|
document-icon
|
|
[:span {:class (stl/css :block-text)}
|
|
(tr "labels.num-of-files" (i18n/c (:files stats)))]]]]]))
|
|
|