mirror of
https://github.com/penpot/penpot.git
synced 2026-09-22 20:06:16 +00:00
* 🎉 Add Menu design-system component Adds Menu, MenuItem, MenuSeparator, SubMenu, and ContextMenu to the shared UI package and exposes them through the CLJS design-system wrapper, with Storybook stories and MDX docs. Built on react-aria-components for keyboard navigation, focus management, and dismissal. Penpot's own DS buttons aren't react-aria-aware, so trigger positioning, focus-on-open, and close-on-select are wired explicitly instead of relying on the library's default trigger detection. Includes a temporary manual-test harness in the dashboard to check the components against the real app. CSS is functional but doesn't match the DS visual design yet — that comes in a follow-up. AI-assisted-by: claude-sonnet-5 * ✨ Add left/right corner placements to Menu design-system component Menu and ContextMenu only exposed 8 of react-aria's placement values, missing every left/right corner variant (right bottom, right top, left bottom, left top) that the top/bottom sides already had via start/end. Add the four missing corners, matching the start/end pattern already used for top/bottom, so a menu can open toward any corner of its trigger. AI-assisted-by: claude-sonnet-5 * ✨ Add drilldown variant to SubMenu design-system component SubMenu only opened as a flyout: a nested popover next to the trigger item. That doesn't scale to a tree too deep or wide for a chain of flyouts, e.g. move-to-project's team -> project nesting, which needs a mobile-style drilldown (replace the current items with the submenu's own, plus a way back) instead. Add a `variant` prop, `"flyout"` (default, unchanged) or `"drilldown"`. Menu and ContextMenu each keep a navigation stack, provided to their content tree via context, so a drilldown SubMenu nested inside another drilldown SubMenu still drills into the same stack and arbitrarily deep trees stay navigable one screen at a time. Switching levels remounts the level's content wrapped in a keyed Fragment rather than updating it in place, since react-stately's Collection requires each item's id to stay stable across an update and the back item's label (and everything under it) genuinely changes identity between levels. AI-assisted-by: claude-sonnet-5 * ♻️ Wire the DS Menu/SubMenu into the dashboard file menu file_menu.cljs used context-menu-a11y's data-driven options list, rendered via a generic recursive renderer. Rewritten as real JSX composition (menu-item*/sub-menu*/menu-separator*) using the DS Menu component, preserving every existing conditional branch (single-file, multi-select, restore-mode, permission gates). "Move to" -> "Move to other team" -> team -> project now uses sub-menu*'s drilldown variant at every level. Split into file-menu-items* (the item tree, no popover of its own) and a thin file-menu* wrapper (Menu, anchored to the "..." button), so grid.cljs can render the same items a second time inside a ContextMenu for right-click, matching the previous behavior of opening either via the button or a right-click anywhere on the row. grid.cljs's trigger handling is simplified accordingly: DS's Menu/ ContextMenu handle their own positioning (including auto-flip near viewport edges) and dismissal internally, so the manual click- coordinate math, the dashboard-local :menu-open/:menu-pos globals, and the portal-on-document* wrapper (Popover already portals itself) are all gone. The now-fully-dead show-file-menu-with-position/ show-file-menu/hide-file-menu actions are removed from data/dashboard.cljs. Also fixes two issues found wiring this up: - The add-shared/unpublish-shared toggle rendered two different menu-item* ids at the same list position; :is-shared can flip while the popover stays open (the action's own side effect), and react-stately's Collection requires an item's id to stay stable across such an update. Both branches now share one id. - Menu's own trigger wrapper (align-self: start, needed generically so it doesn't stretch in an arbitrary parent) overrode .project-thumbnail-actions's centering of the "..." button; grid.scss now re-asserts centering for that specific consumer. Removes the temporary menu-test* harness from dashboard.cljs now that there's a real integration to test against instead. AI-assisted-by: claude-sonnet-5 * 🐛 Fix Menu/ContextMenu popover interaction bugs Found testing the dashboard file menu integration: - Reopening the same trigger right after closing (e.g. right-click, dismiss, right-click again) could silently fail or briefly show two overlapping instances. Closing played a 100ms exit fade, and a reopen landing mid-fade raced the still-live Popover instance. Closing now always skips the exit animation, so by the time any subsequent open request arrives there's no ambiguous in-between DOM state left to race. - Right-clicking a different row while one file's context menu was open didn't close the first one. Menu/ContextMenu don't use react-aria-components' own MenuTrigger (Penpot's DS buttons aren't react-aria-pressable), so they also don't get its built-in RootMenuTriggerStateContext coordination between sibling instances. A window CustomEvent broadcast restores it: opening announces this instance's id, and every other mounted instance closes on hearing a different one. - With that coordination in place, right-clicking elsewhere still did nothing at all: Popover defaults to modal, which marks the rest of the app inert (unfocusable *and* unclickable, not just visually blocked) while open. Correct for a real Dialog, wrong for a lightweight dismissable menu. Fixed with isNonModal on all three Popover usages (Menu, ContextMenu, SubMenu's flyout). - isNonModal has its own side effect: react-aria only wires up its click-outside-closes behavior when a popover is "dismissable", which isNonModal forces off (for anything but a submenu flyout) with no separate prop to turn back on. Reimplemented directly: a pointerdown landing outside the popover's own rendered content closes it, via a ref now passed to Popover. AI-assisted-by: claude-sonnet-5 * 🐛 Fix Menu visual styling and two overflow bugs Border and shadow, to match the legacy context-menu-a11y menu this replaces: the DS component had neither (a filter: drop-shadow with a different blur radius stood in for the shadow, and there was no border at all). Used the pattern already established by sibling DS dropdowns (options-dropdown.scss et al.) rather than porting the legacy tokens directly — border: 1px solid var(--color-background-quaternary) + box-shadow: 0 0 12px 0 var(--color-shadow-dark), both already in use elsewhere in this same file. Found two real bugs verifying that against a long "move to" list: - .menuItem/.separator had no flex-shrink: 0, so once a list's natural height exceeded the menu's max-block-size, flexbox shrank every row to fit them all rather than triggering the scrollbar — overflow only kicks in after flex-shrink has done its best, and shrinking was never opted out of. - The menu's own fixed max-block-size: 300px ignored react-aria's Popover, which sets its own max-height (inline, on our direct parent) to whatever space is actually available between the trigger and the viewport edge. In a small viewport that computed value can be under 300px; since the parent has no overflow of its own, our independent 300px cap just rendered straight past it and off the edge of the window. max-block-size: inherit picks up the parent's own computed value instead, at the cost of no longer capping how tall the menu can get when there's plenty of room (verified: 348px in a normal-height viewport, vs the old fixed 300px) — an acceptable tradeoff against content becoming inaccessible. AI-assisted-by: claude-sonnet-5 * 💄 Adjust Menu design-system component item states and spacing Give menu items a distinct keyboard-focus ring (accent-primary outline plus tertiary background) separate from the mouse hover/click state, which keeps its existing quaternary background unchanged. Restyle disabled items with a tertiary background and secondary text color, shrink the submenu chevron to 12x12, and tighten the menu's vertical padding to 4px. * 📚 Document drilldown submenu and tighten Menu docs Add the drilldown submenu story to the Menu docs page, show the idiomatic controlled-state shape in the usage example (callbacks bound in the let with mf/use-fn, explicit deref of the open state), and trim the prose down to the information a consumer needs. * 🐛 Fix Menu outside-click closing on its own trigger and submenus useCloseOnOutsideClick restores the dismiss behavior isNonModal turns off, but it tested containment against the popover element alone. That missed two cases react-aria's own useOverlay accounts for. A root Popover wraps its content in a display:contents div and portals every SubmenuTrigger's nested popover into that same div, so a flyout submenu is a sibling of the popover, not a descendant. Pressing an item in one counted as an outside click: the whole tree unmounted on pointerdown and the item's action never fired on pointerup. Test the group container instead. The trigger was likewise treated as outside, so closing on its pointerdown let the click's own handler read the already-false open state and reopen the menu — a trigger wired to a toggle could never close it. Exclude it in Menu; ContextMenu keeps the old behavior, since right-clicking elsewhere should reopen it against a new anchor. * 🐛 Target the clicked file when it is not in the dashboard selection The file menu adopted the whole selection whenever it was non-empty, guarding only against it being empty. That left the case where the selection holds files this row is not one of: toggle-file-select is a no-op across projects, so shift-right-clicking a file in another project leaves the previous project's selection intact and the menu opened on the pointed-at file while offering rename, duplicate, move and delete for a different one. Adopt the selection only when it actually contains this file, which covers the deferred-dispatch case the previous guard was written for just as well. * 🐛 Drop the file menu teams cache that outlived a logout The cache was a module-global defonce atom, and logging out does not reload the page — it resets the store and navigates. The next profile to sign in on the same tab therefore opened its first file menu with the previous account's team and project names listed under "Move to", until the background fetch replaced them. The cache only ever saved the brief absence of one submenu, which is already guarded on having data and so does not shift any layout, so remove it rather than scope it to a profile. Dispose the subscription too: it wrote to component state after unmount. * 🐛 Keep the Menu open when its own trigger takes focus Excluding the trigger from the outside-click dismiss was not enough to make a toggle trigger able to close the menu: usePopover passes shouldCloseOnBlur unconditionally, and useOverlay acts on it regardless of isNonModal, so focus moving to the trigger on its own pointerdown closed the popover before the click ran. The click then read an open state that was already false and reopened it. shouldCloseOnInteractOutside is the one exception useOverlay consults before closing on blur, so use it to exempt the trigger. * 🔧 Add interaction tests for the Menu component Cover the two dismissal regressions just fixed — closing the menu from its own trigger, and a press inside a flyout submenu not being treated as an outside click — plus drilldown navigation, the navigation stack resetting between open/close cycles, and Escape and outside click. Both regression tests fail against the code as it was before the fixes. The story trigger now toggles instead of only ever opening, which is what a real caller does (the dashboard's own is a swap!) and what makes the reopen bug observable at all. * ✨ Add max-width, density, and drilldown sizing to Menu/ContextMenu Add a max-width prop (default 250px) to Menu, ContextMenu, and flyout SubMenu, and an is-dense prop to Menu/ContextMenu that shrinks every item — including nested flyout SubMenus, via a shared density context — to a 28px row. Pin a drilldown SubMenu's popover to at least the root level's own size, so navigating into a shorter or narrower list doesn't shrink the menu mid-navigation. Also truncate a plain MenuItem's text with an ellipsis instead of letting it wrap and blow out the row height, matching the existing SubMenu trigger label, and fix that label's own truncation: it was missing min-inline-size: 0, without which a flex item can't shrink below its content size and text-overflow: ellipsis never engages. Exposed through the ClojureScript facade as :max-width/:is-dense, documented with new example canvases, and covered by five new Storybook interaction tests, each verified to fail without its corresponding fix. * ♻️ Wire the DS Menu/ContextMenu into the dashboard project menu Replace the legacy context-menu-a11y-based project menu (grid, sidebar, and per-project file view) with the DS Menu/ContextMenu components, mirroring the earlier file menu migration. Drop the manual :menu-open/:menu-pos position tracking in favor of the DS components' own positioning, and split project-menu-items* out so both the "..." trigger and right-click share the same options. The hidden file input behind the "Import" option moves out of the popover content and into whichever parent stays mounted regardless of the menu's own open state: the DS popover really unmounts its content on close (unlike context-menu-a11y, which only hid it), and selecting "Import" closes the menu in the same tick a ref owned inside it would already be gone. Add an onOpenChange notification to ContextMenu (it stays uncontrolled, this only reports state changes) so the project row's "..."/pin/add-file actions can stay visible for as long as either menu is open, the same way they already do on hover. Fix a related visibility bug this exposed: closing a menu restores focus to its trigger regardless of whether the open happened via mouse or keyboard, so :focus-within alone kept the actions visible after closing with the pointer away — swapped for :has(:focus-visible), which only matches real keyboard navigation. * 🐛 Forward MenuItem's id to the DOM as data-testid MenuItem's function signature never forwarded anything beyond its explicitly-typed props to the underlying RACMenuItem, so a caller's id — meant as a stable per-item identifier — only ever reached the DOM as react-aria's own internal data-key, never as data-testid. This silently broke dashboard.spec.js's "Multiple elements in context" test after the file menu's migration to this component, since every existing menu item id was already relied on as its test id. id is already unique per item for selection/on-action, so deriving data-testid from it directly means every item is reachable in a test with no separate prop to remember to pass. SubMenu's own trigger row is a MenuItem too, so this covers it for free. * 🔧 Add Playwright coverage for the project options menu Covers all four places the migrated project menu is reachable: the dashboard grid's "..." button and title right-click, the sidebar's right-click, and the per-project files page's "..." button. Checks rename/duplicate/pin/move-to/delete render (and that the default Drafts project correctly hides all of them), that rename opens the inline editor, that delete opens the confirm modal, and that the move-to submenu lists other teams. Also drop an unused React import from context_menu.stories.jsx, spotted in passing. * ♻️ Add datatest id * ♻️ Fix linter * 🐛 Build @penpot/ui automatically after pnpm install packages/ui/dist is gitignored (build output) and nothing in the install pipeline built it, so a fresh checkout — CI included — never had it. Any code importing "@penpot/ui/menu" (the frontend's own cljs-runtime tests among them) failed at module resolution with ERR_MODULE_NOT_FOUND rather than any real test failure. Build it in postinstall, the same way plugins-runtime already does, so it's always present after `pnpm install` without a separate manual build step. * 🔥 Remove flaky Menu dense/ellipsis Storybook tests Test Dense Shrinks Items and Test Long Label Ellipses Instead Of Wrapping asserted computed pixel styles that passed consistently locally (including with a fresh packages/ui install) but failed in CI, suggesting a CI-only timing/environment discrepancy in when the computed style stabilizes. Dropping them rather than chasing a non-reproducible flake. * 💄 Open the file/project options menu right, top-aligned Switch the dashboard file and project "..." options menus from "bottom end" to "right top" placement, so they open beside the trigger button instead of below it. * 🐛 Stop drilldown SubMenu jumping to the opposite edge A drilldown SubMenu swaps its parent Menu/ContextMenu popover's own content in place, and react-aria re-runs its flip/collision placement on every layout change. Drilling into a shorter level than the root could shrink the popover enough that react-aria decided there was now room on the other side, flipping it there — a visible jump even though the popover never actually moved from the caller's point of view. The previous fix padded every drilled-in level out to the root's own min-inline-size/min-block-size so the popover never got small enough to trigger a re-flip, but that meant a level naturally much shorter than the root still rendered at the root's full height. Replace it with shouldUpdatePosition={false} on the Popover for as long as any level is drilled in. This freezes whichever edge react-aria already resolved for the root, so a shorter level just shrinks from the opposite edge instead of triggering a new placement decision, and a taller level grows from that same opposite edge in the direction the root already opened. shouldUpdatePosition goes back to true once the stack returns to the root, so a fresh open still resolves normally. * ♻️ Update menu placements and use buttons from DS --------- Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
1545 lines
62 KiB
Clojure
1545 lines
62 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 SUBSIDIARY SL
|
|
|
|
(ns app.main.ui.dashboard.sidebar
|
|
(:require-macros [app.main.style :as stl])
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.main.data.auth :as da]
|
|
[app.main.data.common :as dcm]
|
|
[app.main.data.dashboard :as dd]
|
|
[app.main.data.event :as ev]
|
|
[app.main.data.modal :as modal]
|
|
[app.main.data.nitrate :as dnt]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.team :as dtm]
|
|
[app.main.refs :as refs]
|
|
[app.main.router :as rt]
|
|
[app.main.store :as st]
|
|
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
|
|
dropdown-menu-item*]]
|
|
[app.main.ui.components.link :refer [link*]]
|
|
[app.main.ui.components.organization-avatar :refer [organization-avatar*]]
|
|
[app.main.ui.dashboard.check-updates :as dcu]
|
|
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
|
|
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
|
|
[app.main.ui.dashboard.project-menu :refer [project-menu-items*]]
|
|
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
|
|
get-subscription-type
|
|
menu-team-icon*
|
|
nitrate-current-plan*
|
|
nitrate-sidebar*
|
|
show-subscription-dashboard-banner?
|
|
subscription-sidebar*]]
|
|
[app.main.ui.dashboard.team-form]
|
|
[app.main.ui.ds.buttons.button :refer [button*]]
|
|
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
|
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
|
[app.main.ui.ds.layout.menu :refer [context-menu*]]
|
|
[app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]]
|
|
[app.main.ui.icons :as deprecated-icon]
|
|
[app.main.ui.nitrate.nitrate-form]
|
|
[app.util.dom :as dom]
|
|
[app.util.dom.dnd :as dnd]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[app.util.keyboard :as kbd]
|
|
[app.util.timers :as ts]
|
|
[beicon.v2.core :as rx]
|
|
[cuerdas.core :as str]
|
|
[goog.functions :as f]
|
|
[rumext.v2 :as mf]))
|
|
|
|
(def ^:private clear-search-icon
|
|
(deprecated-icon/icon-xref :delete-text (stl/css :clear-search-icon)))
|
|
|
|
(def ^:private search-icon
|
|
(deprecated-icon/icon-xref :search (stl/css :search-icon)))
|
|
|
|
(def ^:private tick-icon
|
|
(deprecated-icon/icon-xref :tick (stl/css :tick-icon)))
|
|
|
|
(def ^:private logo-icon
|
|
(deprecated-icon/icon-xref :logo (stl/css :logo-icon)))
|
|
|
|
(def ^:private add-icon
|
|
(deprecated-icon/icon-xref :add (stl/css :add-icon)))
|
|
|
|
(def ^:private arrow-icon
|
|
(deprecated-icon/icon-xref :arrow (stl/css :arrow-icon)))
|
|
|
|
(def ^:private menu-icon
|
|
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
|
|
|
|
(def ^:private organization-menu-icon
|
|
(deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon)))
|
|
|
|
(def ^:private organization-menu-icon-open
|
|
(deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon-open)))
|
|
|
|
(def ^:private pin-icon
|
|
(deprecated-icon/icon-xref :pin (stl/css :pin-icon)))
|
|
|
|
(def ^:private exit-icon
|
|
(deprecated-icon/icon-xref :exit (stl/css :exit-icon)))
|
|
|
|
(def ^:private add-organization-icon
|
|
(deprecated-icon/icon-xref :add (stl/css :add-organization-icon)))
|
|
|
|
(def ^:private arrow-up-right-icon
|
|
(deprecated-icon/icon-xref :arrow-up-right (stl/css :arrow-up-right-icon)))
|
|
|
|
(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon")
|
|
(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle")
|
|
|
|
(defn schedule-focus-by-id!
|
|
[ref element-id]
|
|
(when-let [h (mf/ref-val ref)]
|
|
(ts/dispose! h))
|
|
(mf/set-ref-val! ref
|
|
(ts/schedule
|
|
#(dom/focus-and-untabbable! (dom/get-element element-id)))))
|
|
|
|
(mf/defc sidebar-project*
|
|
{::mf/private true}
|
|
[{:keys [item is-selected]}]
|
|
(let [dstate (mf/deref refs/dashboard-local)
|
|
selected-files (:selected-files dstate)
|
|
selected-project (:selected-project dstate)
|
|
edit-id (:project-for-edit dstate)
|
|
|
|
local* (mf/use-state
|
|
#(do {:edition? (= (:id item) edit-id)
|
|
:dragging? false}))
|
|
|
|
local (deref local*)
|
|
|
|
project-id (get item :id)
|
|
|
|
focus-timer-ref (use-focus-timer-ref)
|
|
|
|
on-click
|
|
(mf/use-fn
|
|
(mf/deps project-id)
|
|
(fn []
|
|
(st/emit! (dcm/go-to-dashboard-files :project-id project-id))))
|
|
|
|
on-key-down
|
|
(mf/use-fn
|
|
(mf/deps project-id)
|
|
(fn [event]
|
|
(when (kbd/enter? event)
|
|
(schedule-focus-by-id! focus-timer-ref (str project-id))
|
|
(st/emit! (dcm/go-to-dashboard-files :project-id project-id)))))
|
|
|
|
|
|
on-edit-open
|
|
(mf/use-fn #(swap! local* assoc :edition? true))
|
|
|
|
on-edit
|
|
(mf/use-fn
|
|
(mf/deps item)
|
|
(fn [name]
|
|
(when-not (str/blank? name)
|
|
(st/emit! (-> (dd/rename-project (assoc item :name name))
|
|
(with-meta {::ev/origin "dashboard:sidebar"}))))
|
|
(swap! local* assoc :edition? false)))
|
|
|
|
on-drag-enter
|
|
(mf/use-fn
|
|
(mf/deps selected-project)
|
|
(fn [e]
|
|
(when (dnd/has-type? e "penpot/files")
|
|
(dom/prevent-default e)
|
|
(when-not (dnd/from-child? e)
|
|
(when (not= selected-project (:id item))
|
|
(swap! local* assoc :dragging? true))))))
|
|
|
|
on-drag-over
|
|
(mf/use-fn
|
|
(fn [e]
|
|
(when (dnd/has-type? e "penpot/files")
|
|
(dom/prevent-default e))))
|
|
|
|
on-drag-leave
|
|
(mf/use-fn
|
|
(fn [e]
|
|
(when-not (dnd/from-child? e)
|
|
(swap! local* assoc :dragging? false))))
|
|
|
|
on-drop-success
|
|
(mf/use-fn
|
|
(mf/deps project-id)
|
|
(fn [_]
|
|
(st/emit! (dcm/go-to-dashboard-files :project-id project-id)
|
|
(ntf/success (tr "dashboard.success-move-file")))))
|
|
|
|
on-drop
|
|
(mf/use-fn
|
|
(mf/deps item selected-files)
|
|
(fn [_]
|
|
(swap! local* assoc :dragging? false)
|
|
(when (not= selected-project (:id item))
|
|
(let [data {:ids selected-files
|
|
:project-id (:id item)}
|
|
mdata {:on-success on-drop-success}]
|
|
(st/emit! (dd/move-files (with-meta data mdata)))))))]
|
|
|
|
[:> context-menu* {:aria-label (tr "dashboard.options")
|
|
:trigger
|
|
(mf/html
|
|
[:li {:tab-index "0"
|
|
:class (stl/css-case :project-element true
|
|
:sidebar-nav-item true
|
|
:current is-selected
|
|
:dragging (:dragging? local))
|
|
:on-click on-click
|
|
:on-key-down on-key-down
|
|
:on-double-click on-edit-open
|
|
:on-drag-enter on-drag-enter
|
|
:on-drag-over on-drag-over
|
|
:on-drag-leave on-drag-leave
|
|
:on-drop on-drop}
|
|
(if (:edition? local)
|
|
[:& inline-edition {:content (:name item)
|
|
:on-end on-edit}]
|
|
[:span {:class (stl/css :element-title)} (:name item)])])}
|
|
[:> project-menu-items* {:project item :on-edit on-edit-open}]]))
|
|
|
|
(mf/defc sidebar-search*
|
|
{::mf/private true}
|
|
[{:keys [search-term team-id]}]
|
|
(let [search-term (d/nilv search-term "")
|
|
focused? (mf/use-state false)
|
|
emit! (mf/use-memo #(f/debounce st/emit! 500))
|
|
|
|
focus-timer-ref (use-focus-timer-ref)
|
|
|
|
on-search-blur
|
|
(mf/use-fn
|
|
(fn [_]
|
|
(reset! focused? false)))
|
|
|
|
on-search-change
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [value (dom/get-target-val event)]
|
|
(emit! (dcm/go-to-dashboard-search :term value)))))
|
|
|
|
on-clear-click
|
|
(mf/use-fn
|
|
(mf/deps team-id)
|
|
(fn [e]
|
|
(emit! (dcm/go-to-dashboard-search))
|
|
(let [search-input (dom/get-element "search-input")]
|
|
(dom/clean-value! search-input)
|
|
(dom/focus! search-input)
|
|
(dom/prevent-default e)
|
|
(dom/stop-propagation e))))
|
|
|
|
on-key-press
|
|
(mf/use-fn
|
|
(fn [e]
|
|
(when (kbd/enter? e)
|
|
(schedule-focus-by-id! focus-timer-ref "dashboard-search-title")
|
|
(dom/prevent-default e)
|
|
(dom/stop-propagation e))))
|
|
|
|
handle-clear-search
|
|
(mf/use-fn
|
|
(mf/deps on-clear-click)
|
|
(fn [event]
|
|
(when (kbd/enter? event)
|
|
(on-clear-click event))))]
|
|
|
|
[:form {:class (stl/css :sidebar-search)}
|
|
[:input {:class (stl/css :input-text)
|
|
:key "images-search-box"
|
|
:id "search-input"
|
|
:type "text"
|
|
:aria-label (tr "dashboard.search-placeholder")
|
|
:placeholder (tr "dashboard.search-placeholder")
|
|
:default-value search-term
|
|
:auto-complete "off"
|
|
;; :on-focus on-search-focus
|
|
:on-blur on-search-blur
|
|
:on-change on-search-change
|
|
:on-key-press on-key-press
|
|
:ref #(when % (set! (.-value %) search-term))}]
|
|
|
|
(if (or @focused? (seq search-term))
|
|
[:button {:class (stl/css :search-btn :clear-search-btn)
|
|
:tab-index "0"
|
|
:aria-label "dashboard-clear-search"
|
|
:on-click on-clear-click
|
|
:on-key-down handle-clear-search}
|
|
clear-search-icon]
|
|
|
|
[:button {:class (stl/css :search-btn)
|
|
:aria-label "dashboard-search"
|
|
:on-click on-clear-click}
|
|
search-icon])]))
|
|
|
|
(mf/defc organizations-selector-dropdown*
|
|
{::mf/private true}
|
|
[{:keys [organization organizations profile] :rest props}]
|
|
(let [teams (mf/deref refs/teams)
|
|
|
|
subscription-type (get-subscription-type (-> profile :props :subscription))
|
|
|
|
team-count (count teams)
|
|
|
|
account-age-days (dnt/account-age-days profile)
|
|
|
|
on-organization-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [team-id (-> (dom/get-current-target event)
|
|
(dom/get-data "value")
|
|
(uuid/parse))]
|
|
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
|
|
|
|
on-create-organization-click
|
|
(mf/use-fn
|
|
(mf/deps account-age-days profile subscription-type team-count)
|
|
(fn []
|
|
(if (and (not= subscription-type "unlimited")
|
|
(dnt/is-valid-license? profile))
|
|
(dnt/go-to-nitrate-ac-create-organization
|
|
"dashboard:organization-switcher")
|
|
(st/emit!
|
|
(ev/event
|
|
(cond-> {::ev/name "open-subscription-modal"
|
|
::ev/origin "dashboard:organization-switcher"
|
|
:product "nitrate:enterprise"
|
|
:source "frontend"
|
|
:has-teams (pos? team-count)
|
|
:team-count team-count}
|
|
(some? account-age-days)
|
|
(assoc :account-age-days account-age-days)))
|
|
(dnt/show-nitrate-popup
|
|
:nitrate-form
|
|
(cond-> {:subscription-start-origin "dashboard:organization-switcher"}
|
|
(= subscription-type "unlimited")
|
|
(assoc :show-contact-sales-option true)))))))
|
|
|
|
on-go-to-cc-click
|
|
(mf/use-fn
|
|
(mf/deps organization profile)
|
|
(fn []
|
|
;; Navigate to active organization if user owns it, otherwise to last visited organization
|
|
(if (and (:id organization)
|
|
(= (:id profile) (:owner-id organization)))
|
|
(dnt/go-to-nitrate-ac {:organization-id (:id organization)
|
|
:organization-slug (:slug organization)})
|
|
(dnt/go-to-nitrate-ac))))
|
|
|
|
empty-organization (d/seek #(nil? (:id %)) organizations)
|
|
default-team-id (or (:default-team-id empty-organization)
|
|
(:default-team-id profile))
|
|
|
|
organizations (filter :id organizations)
|
|
|
|
is-valid-license? (dnt/is-valid-license? profile)]
|
|
|
|
[:> dropdown-menu* props
|
|
|
|
[:> dropdown-menu-item* {:on-click on-organization-click
|
|
:data-value default-team-id
|
|
:class (stl/css :organization-dropdown-item)}
|
|
[:span {:class (stl/css :my-teams-icon)}
|
|
[:> raw-svg* {:id penpot-logo-icon-subtle}]]
|
|
[:span {:class (stl/css :team-text)
|
|
:title (tr "dashboard.other-teams")}
|
|
(tr "dashboard.other-teams")]
|
|
(when (= default-team-id (:default-team-id organization))
|
|
tick-icon)]
|
|
(when (seq organizations)
|
|
[:*
|
|
[:hr {:role "separator" :class (stl/css :team-separator)}]
|
|
[:li {:role "presentation" :class (stl/css :organization-section-label)}
|
|
(tr "dashboard.section.organizations")]])
|
|
|
|
(for [organization-item organizations]
|
|
[:> dropdown-menu-item* {:on-click on-organization-click
|
|
:data-value (:default-team-id organization-item)
|
|
:class (stl/css :organization-dropdown-item)
|
|
:key (str (:default-team-id organization-item))}
|
|
[:> organization-avatar* {:organization organization-item :size "xxl"}]
|
|
[:span {:class (stl/css :team-text)
|
|
:title (:name organization-item)} (:name organization-item)]
|
|
(when (= (:default-team-id organization-item) (:default-team-id organization))
|
|
tick-icon)])
|
|
|
|
[:hr {:role "separator" :class (stl/css :team-separator)}]
|
|
[:> dropdown-menu-item* {:on-click on-create-organization-click
|
|
:class (stl/css :organization-dropdown-item :action)}
|
|
[:span {:class (stl/css :icon-wrapper)} add-organization-icon]
|
|
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-organization")]]
|
|
(when is-valid-license?
|
|
[:> dropdown-menu-item* {:on-click on-go-to-cc-click
|
|
:class (stl/css :organization-dropdown-item :action)}
|
|
[:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon]
|
|
[:span {:class (stl/css :team-text)} (tr "dashboard.go-to-admin-console")]])]))
|
|
|
|
(mf/defc teams-selector-dropdown*
|
|
{::mf/private true}
|
|
[{:keys [team profile teams] :rest props}]
|
|
(let [default-team-id (or (->> teams
|
|
vals
|
|
(filter :is-default)
|
|
first
|
|
:id)
|
|
(:default-team-id profile))
|
|
|
|
teams (dissoc teams default-team-id)
|
|
on-create-team-click
|
|
(mf/use-fn
|
|
(mf/deps team)
|
|
(fn []
|
|
(if (contains? cf/flags :admin-console)
|
|
(st/emit! (dtm/check-and-create-team (:id team)))
|
|
(st/emit! (modal/show :team-form {})))))
|
|
|
|
on-team-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [team-id (-> (dom/get-current-target event)
|
|
(dom/get-data "value")
|
|
(uuid/parse))]
|
|
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
|
|
|
|
[:> dropdown-menu* props
|
|
[:> dropdown-menu-item* {:on-click on-team-click
|
|
:data-value default-team-id
|
|
:class (stl/css-case :team-dropdown-item true
|
|
:team-dropdown-item-no-logo (contains? cf/flags :admin-console))}
|
|
(when-not (contains? cf/flags :admin-console)
|
|
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon])
|
|
|
|
[:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]
|
|
(when (= default-team-id (:id team))
|
|
tick-icon)]
|
|
|
|
(when (and (contains? cf/flags :admin-console)
|
|
(seq (remove :is-default (vals teams))))
|
|
[:*
|
|
[:hr {:role "separator" :class (stl/css :team-separator)}]
|
|
[:li {:role "presentation" :class (stl/css :organization-section-label)}
|
|
(tr "dashboard.section.teams")]])
|
|
|
|
(for [team-item (remove :is-default (vals teams))]
|
|
[:> dropdown-menu-item* {:on-click on-team-click
|
|
:data-value (:id team-item)
|
|
:class (stl/css :team-dropdown-item)
|
|
:key (str (:id team-item))}
|
|
[:img {:src (cf/resolve-team-photo-url team-item)
|
|
:class (stl/css :team-picture)
|
|
:alt (:name team-item)}]
|
|
|
|
(if (and (contains? cf/flags :subscriptions)
|
|
(#{"unlimited" "enterprise"} (get-subscription-type (:subscription team-item))))
|
|
[:div {:class (stl/css :team-text-with-icon)}
|
|
[:span {:class (stl/css :team-text) :title (:name team-item)} (:name team-item)]
|
|
[:> menu-team-icon* {:subscription-type (get-subscription-type (:subscription team-item))}]]
|
|
[:span {:class (stl/css :team-text)
|
|
:title (:name team-item)} (:name team-item)])
|
|
(when (= (:id team-item) (:id team))
|
|
tick-icon)])
|
|
|
|
|
|
[:hr {:role "separator" :class (stl/css :team-separator)}]
|
|
[:> dropdown-menu-item* {:on-click on-create-team-click
|
|
:class (stl/css :team-dropdown-item :action)}
|
|
[:span {:class (stl/css :icon-wrapper)} add-icon]
|
|
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
|
|
|
|
(mf/defc team-options-dropdown*
|
|
{::mf/private true}
|
|
[{:keys [team profile] :rest props}]
|
|
(let [go-members #(st/emit! (dcm/go-to-dashboard-members))
|
|
go-invitations #(st/emit! (dcm/go-to-dashboard-invitations))
|
|
go-webhooks #(st/emit! (dcm/go-to-dashboard-webhooks))
|
|
go-settings #(st/emit! (dcm/go-to-dashboard-settings))
|
|
|
|
members (get team :members)
|
|
permissions (get team :permissions)
|
|
can-rename? (or (:is-owner permissions)
|
|
(:is-admin permissions))
|
|
|
|
on-success
|
|
(fn []
|
|
;; FIXME: this should be handled in the event, not here
|
|
(let [team-id (:default-team-id profile)]
|
|
(rx/of (dcm/go-to-dashboard-recent :team-id team-id)
|
|
(modal/hide))))
|
|
|
|
on-error
|
|
(fn [error]
|
|
(let [code (-> error ex-data :code)]
|
|
(condp = code
|
|
:only-owner-can-delete-team
|
|
(rx/of (ntf/error (tr "errors.team-leave.only-owner-can-delete")))
|
|
|
|
: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))))
|
|
|
|
leave-fn
|
|
(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! (dtm/leave-current-team (with-meta params
|
|
{:on-success on-success
|
|
:on-error on-error}))))))
|
|
delete-fn
|
|
(mf/use-fn
|
|
(mf/deps team on-success on-error)
|
|
(fn []
|
|
(st/emit! (dtm/delete-team (with-meta team {:on-success on-success
|
|
:on-error on-error})))))
|
|
on-rename-clicked
|
|
(mf/use-fn
|
|
(mf/deps team)
|
|
(fn []
|
|
(st/emit! (modal/show :team-form {:team team}))))
|
|
|
|
on-leave-clicked
|
|
(mf/use-fn
|
|
(mf/deps leave-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 leave-fn})))
|
|
|
|
on-leave-as-owner-clicked
|
|
(mf/use-fn
|
|
(mf/deps team profile leave-fn)
|
|
(fn []
|
|
(st/emit! (dtm/fetch-members)
|
|
(modal/show
|
|
{:type :leave-and-reassign
|
|
:profile profile
|
|
:team team
|
|
:accept leave-fn}))))
|
|
|
|
leave-and-close
|
|
(mf/use-fn
|
|
(mf/deps team delete-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 delete-fn})))
|
|
|
|
on-delete-clicked
|
|
(mf/use-fn
|
|
(mf/deps team delete-fn)
|
|
(fn []
|
|
;; Fetch fresh team data to check current permission, then show appropriate modal
|
|
(st/emit! (dtm/check-and-delete-team {:team-id (:id team)
|
|
:delete-fn delete-fn}))))]
|
|
[:> dropdown-menu* props
|
|
|
|
[:> dropdown-menu-item* {:on-click go-members
|
|
:class (stl/css :team-options-item)
|
|
:data-testid "team-members"}
|
|
(tr "labels.members")]
|
|
[:> dropdown-menu-item* {:on-click go-invitations
|
|
:class (stl/css :team-options-item)
|
|
:data-testid "team-invitations"}
|
|
(tr "labels.invitations")]
|
|
|
|
(when (contains? cf/flags :webhooks)
|
|
[:> dropdown-menu-item* {:on-click go-webhooks
|
|
:class (stl/css :team-options-item)}
|
|
(tr "labels.webhooks")])
|
|
|
|
[:> dropdown-menu-item* {:on-click go-settings
|
|
:class (stl/css :team-options-item)
|
|
:data-testid "team-settings"}
|
|
(tr "labels.settings")]
|
|
|
|
[:hr {:class (stl/css :team-option-separator)}]
|
|
(when can-rename?
|
|
[:> dropdown-menu-item* {:on-click on-rename-clicked
|
|
:class (stl/css :team-options-item)
|
|
:data-testid "rename-team"}
|
|
(tr "labels.rename")])
|
|
|
|
(cond
|
|
(= (count members) 1)
|
|
[:> dropdown-menu-item* {:on-click leave-and-close
|
|
:class (stl/css :team-options-item)}
|
|
(tr "dashboard.leave-team")]
|
|
|
|
|
|
(get-in team [:permissions :is-owner])
|
|
[:> dropdown-menu-item* {:on-click on-leave-as-owner-clicked
|
|
:class (stl/css :team-options-item)
|
|
:data-testid "leave-team"}
|
|
(tr "dashboard.leave-team")]
|
|
|
|
(> (count members) 1)
|
|
[:> dropdown-menu-item* {:on-click on-leave-clicked
|
|
:class (stl/css :team-options-item)}
|
|
(tr "dashboard.leave-team")])
|
|
|
|
(when (get-in team [:permissions :is-owner])
|
|
[:> dropdown-menu-item* {:on-click on-delete-clicked
|
|
:class (stl/css :team-options-item :warning)
|
|
:data-testid "delete-team"}
|
|
(tr "dashboard.delete-team")])]))
|
|
|
|
(mf/defc organization-options-dropdown*
|
|
{::mf/private true}
|
|
[{:keys [organization profile teams] :rest props}]
|
|
(let [default-team-id (mf/with-memo [teams]
|
|
(->> teams
|
|
(filter :is-default)
|
|
first
|
|
:id))
|
|
non-default-teams (mf/with-memo [teams]
|
|
(remove :is-default teams))
|
|
owned-teams (mf/with-memo [non-default-teams]
|
|
(filter #(dm/get-in % [:permissions :is-owner]) non-default-teams))
|
|
not-owned-teams (mf/with-memo [non-default-teams]
|
|
(remove #(dm/get-in % [:permissions :is-owner]) non-default-teams))
|
|
owned-teams-members-loaded?
|
|
(mf/with-memo [owned-teams]
|
|
(every? #(contains? % :members) owned-teams))
|
|
teams-to-transfer (mf/with-memo [owned-teams]
|
|
(filter #(> (count (:members %)) 1) owned-teams))
|
|
|
|
on-error
|
|
(mf/use-fn
|
|
(fn [error]
|
|
(let [code (-> error ex-data :code)
|
|
;; Map error codes to their translation keys
|
|
error-map {:not-valid-teams "errors.organization-leave.no-valid-teams"
|
|
:organization-owner-cannot-leave "errors.organization-leave.organization-owner-cannot-leave"
|
|
:only-owner-can-delete-team "errors.team-leave.only-owner-can-delete"
|
|
:no-enough-members-for-leave "errors.team-leave.insufficient-members"
|
|
:member-does-not-exist "errors.team-leave.member-does-not-exists"
|
|
:owner-cant-leave-team "errors.team-leave.owner-cant-leave"}]
|
|
|
|
(if-let [tr-key (get error-map code)]
|
|
(rx/of (dtm/fetch-teams)
|
|
(modal/hide)
|
|
(ntf/error (tr tr-key)))
|
|
(rx/throw error)))))
|
|
|
|
leave-fn
|
|
(mf/use-fn
|
|
(mf/deps on-error organization default-team-id not-owned-teams owned-teams)
|
|
(fn [{:keys [teams-to-transfer
|
|
member-added-at
|
|
organization-member-count-before]}]
|
|
(let [teams-to-leave (cond->> not-owned-teams
|
|
:always
|
|
(map #(select-keys % [:id]))
|
|
(seq teams-to-transfer)
|
|
(concat teams-to-transfer))
|
|
teams-to-delete (->> owned-teams
|
|
(filter #(= (count (:members %)) 1))
|
|
(map :id))]
|
|
|
|
|
|
(st/emit! (dnt/leave-organization {:id (:id organization)
|
|
:name (:name organization)
|
|
:default-team-id default-team-id
|
|
:teams-to-delete teams-to-delete
|
|
:teams-to-leave teams-to-leave
|
|
:member-added-at member-added-at
|
|
:organization-member-count-before
|
|
organization-member-count-before
|
|
:on-error on-error})))))
|
|
|
|
on-leave-clicked
|
|
(mf/use-fn
|
|
(mf/deps leave-fn profile organization default-team-id teams-to-transfer on-error owned-teams-members-loaded?)
|
|
(fn []
|
|
(when owned-teams-members-loaded?
|
|
(st/emit! (dnt/show-leave-organization-modal {:organization organization
|
|
:profile profile
|
|
:default-team-id default-team-id
|
|
:leave-fn leave-fn
|
|
:teams-to-transfer teams-to-transfer
|
|
:on-error on-error})))))]
|
|
(mf/use-effect
|
|
(mf/deps owned-teams)
|
|
(fn []
|
|
;; Fetch members for any owned team that doesn't have them yet.
|
|
(doseq [team owned-teams
|
|
:when (not (contains? team :members))]
|
|
(st/emit! (dtm/fetch-members (:id team))))))
|
|
[:> dropdown-menu* props
|
|
|
|
[:> dropdown-menu-item* {:on-click on-leave-clicked
|
|
:class (stl/css :team-options-item)}
|
|
(tr "dashboard.leave-organization")]]))
|
|
|
|
|
|
(mf/defc sidebar-organization-switch*
|
|
[{:keys [team profile]}]
|
|
(let [teams (mf/deref refs/teams)
|
|
|
|
subscription-type (get-subscription-type (-> profile :props :subscription))
|
|
|
|
team-count (count teams)
|
|
|
|
account-age-days (dnt/account-age-days profile)
|
|
|
|
current-organization (dtm/team->organization team)
|
|
|
|
;; Find the "personal-projects" teams, and transform them in organizations. When
|
|
;; the selected team is directly accessible but not listed in
|
|
;; membership teams, include only its organization so the organization selector can
|
|
;; show the current selection without leaking the team into the
|
|
;; teams dropdown.
|
|
organizations (mf/with-memo [teams current-organization]
|
|
(cond-> (->> teams
|
|
vals
|
|
(filter :is-default)
|
|
(map dtm/team->organization)
|
|
(d/index-by :id))
|
|
(:id current-organization)
|
|
(assoc (:id current-organization) current-organization)))
|
|
|
|
show-dropdown? (or (dnt/is-valid-license? profile)
|
|
(> (count organizations) 1))
|
|
|
|
organization-teams (mf/with-memo [teams current-organization]
|
|
(->> teams
|
|
vals
|
|
(filter #(= (dm/get-in % [:organization :id]) (:id current-organization)))))
|
|
|
|
default-organization? (nil? (:id current-organization))
|
|
|
|
show-options? (and (not default-organization?)
|
|
(not= (:id profile) (:owner-id current-organization)))
|
|
|
|
show-organizations-menu*
|
|
(mf/use-state false)
|
|
|
|
show-organizations-menu?
|
|
(deref show-organizations-menu*)
|
|
|
|
organizations-rect*
|
|
(mf/use-state nil)
|
|
|
|
organizations-portal-container
|
|
(hooks/use-portal-container :popup)
|
|
|
|
show-organization-options-menu*
|
|
(mf/use-state false)
|
|
|
|
show-organization-options-menu?
|
|
(deref show-organization-options-menu*)
|
|
|
|
on-show-options-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(swap! show-organization-options-menu* not)))
|
|
|
|
close-organization-options-menu
|
|
(mf/use-fn #(reset! show-organization-options-menu* false))
|
|
|
|
on-show-organizations-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(reset! organizations-rect* (dom/get-bounding-rect (dom/get-current-target event)))
|
|
(swap! show-organizations-menu* not)))
|
|
|
|
on-show-organizations-keydown
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(when (or (kbd/space? event)
|
|
(kbd/enter? event))
|
|
(dom/prevent-default event)
|
|
(dom/stop-propagation event)
|
|
(some-> (dom/get-current-target event)
|
|
(dom/click)))))
|
|
close-organizations-menu
|
|
(mf/use-fn #(reset! show-organizations-menu* false))
|
|
|
|
on-create-organization-click
|
|
(mf/use-fn
|
|
(mf/deps account-age-days profile subscription-type team-count)
|
|
(fn []
|
|
(if (and (not= subscription-type "unlimited")
|
|
(dnt/is-valid-license? profile))
|
|
(dnt/go-to-nitrate-ac-create-organization
|
|
"dashboard:organization-switcher")
|
|
(st/emit!
|
|
(ev/event
|
|
(cond-> {::ev/name "open-subscription-modal"
|
|
::ev/origin "dashboard:create-organization-button"
|
|
:product "nitrate:enterprise"
|
|
:source "frontend"
|
|
:has-teams (pos? team-count)
|
|
:team-count team-count}
|
|
(some? account-age-days)
|
|
(assoc :account-age-days account-age-days)))
|
|
(dnt/show-nitrate-popup
|
|
:nitrate-form
|
|
(cond-> {:subscription-start-origin "dashboard:create-organization-button"}
|
|
(= subscription-type "unlimited")
|
|
(assoc :show-contact-sales-option true)))))))]
|
|
(if show-dropdown?
|
|
[:div {:class (stl/css :sidebar-organization-switch)}
|
|
[:div {:class (stl/css :organization-switch-content)}
|
|
[:button {:class (stl/css-case :current-organization true :current-organization-no-options (not show-options?))
|
|
:on-click on-show-organizations-click
|
|
:on-key-down on-show-organizations-keydown
|
|
:aria-expanded show-organizations-menu?
|
|
:aria-haspopup "menu"}
|
|
[:div {:class (stl/css :team-name)}
|
|
(if default-organization?
|
|
[:*
|
|
[:span {:class (stl/css :my-teams-icon-xxxl)}
|
|
[:> raw-svg* {:id penpot-logo-icon-subtle}]]
|
|
[:span {:class (stl/css :team-text)}
|
|
(tr "dashboard.other-teams")]]
|
|
[:*
|
|
[:> organization-avatar* {:organization current-organization :size "xxxl"}]
|
|
[:span {:class (stl/css :team-text)}
|
|
(:name current-organization)]])]
|
|
arrow-icon]
|
|
(when show-options?
|
|
[:> button* {:variant "ghost"
|
|
:type "button"
|
|
:class (stl/css :organization-options-btn)
|
|
:on-click on-show-options-click}
|
|
(if show-organization-options-menu? organization-menu-icon-open organization-menu-icon)])]
|
|
|
|
|
|
;; Organizations dropdown
|
|
(when show-organizations-menu?
|
|
(let [rect (deref organizations-rect*)]
|
|
(mf/portal
|
|
(mf/html
|
|
[:div {:style {:position "fixed"
|
|
:top (dm/str (:top rect) "px")
|
|
:left (dm/str (:left rect) "px")
|
|
:width (dm/str (:width rect) "px")}}
|
|
[:> organizations-selector-dropdown* {:show true
|
|
:on-close close-organizations-menu
|
|
:id "organizations-list"
|
|
:class (stl/css :dropdown :teams-dropdown)
|
|
:organization current-organization
|
|
:profile profile
|
|
:organizations (->> (vals organizations)
|
|
(sort-by (juxt (fn [o] (str/lower (:name o "")))
|
|
:id)))}]])
|
|
|
|
|
|
|
|
organizations-portal-container)))
|
|
;; Organization options
|
|
[:> organization-options-dropdown* {:show show-organization-options-menu?
|
|
:on-close close-organization-options-menu
|
|
:id "team-options"
|
|
:class (stl/css :dropdown :options-dropdown)
|
|
:organization current-organization
|
|
:profile profile
|
|
:teams organization-teams}]]
|
|
[:div {:class (stl/css :selected-organization)}
|
|
[:span {:class (stl/css :organization-penpot-icon)}
|
|
[:> raw-svg* {:id penpot-logo-icon}]]
|
|
"Penpot"
|
|
[:> button* {:variant "ghost"
|
|
:type "button"
|
|
:class (stl/css :create-organization)
|
|
:on-click on-create-organization-click} (tr "dashboard.plus-create-new-organization")]])))
|
|
|
|
(mf/defc sidebar-team-switch*
|
|
[{:keys [team profile]}]
|
|
(let [nitrate? (contains? cf/flags :admin-console)
|
|
organization (:organization team)
|
|
organization-id (when nitrate? (:id organization))
|
|
teams (cond->> (mf/deref refs/teams)
|
|
nitrate?
|
|
(filter #(= (dm/get-in (val %) [:organization :id]) organization-id))
|
|
nitrate?
|
|
(into {}))
|
|
|
|
subscription
|
|
(get team :subscription)
|
|
|
|
subscription-type
|
|
(get-subscription-type subscription)
|
|
|
|
show-team-options-menu*
|
|
(mf/use-state false)
|
|
|
|
show-team-options-menu?
|
|
(deref show-team-options-menu*)
|
|
|
|
show-teams-menu*
|
|
(mf/use-state false)
|
|
|
|
show-teams-menu?
|
|
(deref show-teams-menu*)
|
|
|
|
is-default?
|
|
(:is-default team)
|
|
|
|
on-show-teams-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(swap! show-teams-menu* not)))
|
|
|
|
on-show-teams-keydown
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(when (or (kbd/space? event)
|
|
(kbd/enter? event))
|
|
(dom/prevent-default event)
|
|
(dom/stop-propagation event)
|
|
(some-> (dom/get-current-target event)
|
|
(dom/click)))))
|
|
|
|
close-team-options-menu
|
|
(mf/use-fn #(reset! show-team-options-menu* false))
|
|
|
|
on-show-options-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(swap! show-team-options-menu* not)))
|
|
|
|
on-show-options-keydown
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(when (or (kbd/space? event)
|
|
(kbd/enter? event))
|
|
(dom/prevent-default event)
|
|
(dom/stop-propagation event)
|
|
|
|
(some-> (dom/get-current-target event)
|
|
(dom/click)))))
|
|
|
|
close-teams-menu
|
|
(mf/use-fn #(reset! show-teams-menu* false))]
|
|
|
|
[:div {:class (stl/css :sidebar-team-switch)}
|
|
[:div {:class (stl/css :switch-content)}
|
|
[:button {:class (stl/css :current-team)
|
|
:on-click on-show-teams-click
|
|
:on-key-down on-show-teams-keydown
|
|
:aria-expanded show-teams-menu?
|
|
:aria-haspopup "menu"}
|
|
(cond
|
|
is-default?
|
|
[:div {:class (stl/css-case :team-name true
|
|
:team-name-no-logo nitrate?)}
|
|
(when-not nitrate?
|
|
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon])
|
|
[:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]]
|
|
|
|
(and (contains? cf/flags :subscriptions)
|
|
(not is-default?)
|
|
(or (= "unlimited" subscription-type) (= "enterprise" subscription-type)))
|
|
[:div {:class (stl/css :team-name)}
|
|
[:img {:src (cf/resolve-team-photo-url team)
|
|
:class (stl/css :team-picture)
|
|
:alt (:name team)}]
|
|
[:div {:class (stl/css :team-text-with-icon)}
|
|
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]
|
|
[:> menu-team-icon* {:subscription-type subscription-type}]]]
|
|
|
|
|
|
(and (not is-default?)
|
|
(or (not= "unlimited" subscription-type) (not= "enterprise" subscription-type)))
|
|
[:div {:class (stl/css :team-name)}
|
|
[:img {:src (cf/resolve-team-photo-url team)
|
|
:class (stl/css :team-picture)
|
|
:alt (:name team)}]
|
|
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]])
|
|
|
|
arrow-icon]
|
|
|
|
(when-not is-default?
|
|
[:button {:class (stl/css :switch-options)
|
|
:on-click on-show-options-click
|
|
:aria-label "team-management"
|
|
:tab-index "0"
|
|
:on-key-down on-show-options-keydown}
|
|
menu-icon])]
|
|
|
|
;; Teams Dropdown
|
|
|
|
[:> teams-selector-dropdown* {:show show-teams-menu?
|
|
:on-close close-teams-menu
|
|
:id "team-list"
|
|
:class (stl/css :dropdown :teams-dropdown)
|
|
:team team
|
|
:profile profile
|
|
:teams teams}]
|
|
|
|
[:> team-options-dropdown* {:show show-team-options-menu?
|
|
:on-close close-team-options-menu
|
|
:id "team-options"
|
|
:class (stl/css :dropdown :options-dropdown)
|
|
:team team
|
|
:profile profile}]]))
|
|
|
|
(mf/defc sidebar-content*
|
|
{::mf/private true}
|
|
[{:keys [projects profile section team project search-term default-project] :as props}]
|
|
(let [default-project-id
|
|
(get default-project :id)
|
|
|
|
team-id (get team :id)
|
|
|
|
projects? (= section :dashboard-recent)
|
|
fonts? (= section :dashboard-fonts)
|
|
libs? (= section :dashboard-libraries)
|
|
drafts? (and (= section :dashboard-files)
|
|
(= (:id project) default-project-id))
|
|
container (mf/use-ref nil)
|
|
|
|
overflow* (mf/use-state false)
|
|
overflow? (deref overflow*)
|
|
|
|
nitrate? (contains? cf/flags :admin-console)
|
|
|
|
focus-timer-ref (use-focus-timer-ref)
|
|
|
|
go-projects
|
|
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
|
|
|
|
go-projects-with-key
|
|
(mf/use-fn
|
|
(mf/deps team-id)
|
|
(fn []
|
|
(st/emit!
|
|
(dcm/go-to-dashboard-recent :team-id team-id))
|
|
(schedule-focus-by-id! focus-timer-ref "dashboard-projects-title")))
|
|
|
|
go-fonts
|
|
(mf/use-fn
|
|
(mf/deps team-id)
|
|
#(st/emit! (dcm/go-to-dashboard-fonts :team-id team-id)))
|
|
|
|
go-fonts-with-key
|
|
(mf/use-fn
|
|
(mf/deps team)
|
|
(fn []
|
|
(st/emit!
|
|
(dcm/go-to-dashboard-fonts :team-id team-id))
|
|
(schedule-focus-by-id! focus-timer-ref "dashboard-fonts-title")))
|
|
|
|
go-drafts
|
|
(mf/use-fn
|
|
(mf/deps team-id default-project-id)
|
|
(fn []
|
|
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))))
|
|
|
|
go-drafts-with-key
|
|
(mf/use-fn
|
|
(mf/deps team-id default-project-id)
|
|
(fn []
|
|
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))
|
|
(schedule-focus-by-id! focus-timer-ref "dashboard-drafts-title")))
|
|
|
|
go-libs
|
|
(mf/use-fn
|
|
(mf/deps team-id)
|
|
(fn [] (st/emit! (dcm/go-to-dashboard-libraries :team-id team-id))))
|
|
|
|
go-libs-with-key
|
|
(mf/use-fn
|
|
(mf/deps team-id)
|
|
(fn []
|
|
(st/emit!
|
|
(dcm/go-to-dashboard-libraries :team-id team-id))
|
|
(schedule-focus-by-id! focus-timer-ref "dashboard-libraries-title")))
|
|
|
|
pinned-projects
|
|
(mf/with-memo [projects]
|
|
(->> projects
|
|
(remove :deleted-at)
|
|
(remove :is-default)
|
|
(filter :is-pinned)
|
|
(sort-by :name)
|
|
(not-empty)))]
|
|
|
|
(mf/with-layout-effect [pinned-projects]
|
|
(let [node (mf/ref-val container)
|
|
client-height (.-clientHeight ^js node)
|
|
scroll-height (.-scrollHeight ^js node)]
|
|
(reset! overflow* (> scroll-height client-height))))
|
|
|
|
[:*
|
|
[:div {:class (stl/css :sidebar-content-wrapper)}
|
|
(when nitrate?
|
|
[:div {:class (stl/css :organizations-container)}
|
|
[:> sidebar-organization-switch* {:team team :profile profile}]])
|
|
[:div {:ref container
|
|
:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
|
|
[:> sidebar-team-switch* {:team team :profile profile}]
|
|
|
|
[:> sidebar-search* {:search-term search-term
|
|
:team-id (:id team)}]
|
|
|
|
[:div {:class (stl/css :sidebar-content-section)}
|
|
[:ul {:class (stl/css :sidebar-nav)}
|
|
[:li {:class (stl/css-case :recent-projects true
|
|
:sidebar-nav-item true
|
|
:current projects?)}
|
|
[:> link* {:action go-projects
|
|
:class (stl/css :sidebar-link)
|
|
:keyboard-action go-projects-with-key}
|
|
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
|
|
|
|
[:li {:class (stl/css-case :current drafts?
|
|
:sidebar-nav-item true)}
|
|
[:> link* {:action go-drafts
|
|
:class (stl/css :sidebar-link)
|
|
:keyboard-action go-drafts-with-key}
|
|
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
|
|
|
|
|
|
[:div {:class (stl/css :sidebar-content-section)}
|
|
[:div {:class (stl/css :sidebar-section-title)}
|
|
(tr "labels.sources")]
|
|
[:ul {:class (stl/css :sidebar-nav)}
|
|
[:li {:class (stl/css-case :sidebar-nav-item true
|
|
:current fonts?)}
|
|
[:> link* {:action go-fonts
|
|
:class (stl/css :sidebar-link)
|
|
:keyboard-action go-fonts-with-key
|
|
:data-testid "fonts"}
|
|
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
|
|
[:li {:class (stl/css-case :current libs?
|
|
:sidebar-nav-item true)}
|
|
[:> link* {:action go-libs
|
|
:data-testid "libs-link-sidebar"
|
|
:class (stl/css :sidebar-link)
|
|
:keyboard-action go-libs-with-key}
|
|
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
|
|
|
|
|
|
[:div {:class (stl/css :sidebar-content-section)
|
|
:data-testid "pinned-projects"}
|
|
[:div {:class (stl/css :sidebar-section-title)}
|
|
(tr "labels.pinned-projects")]
|
|
(if (some? pinned-projects)
|
|
[:ul {:class (stl/css :sidebar-nav :pinned-projects)}
|
|
(for [item pinned-projects]
|
|
[:> sidebar-project*
|
|
{:item item
|
|
:key (dm/str (:id item))
|
|
:id (:id item)
|
|
:team-id (:id team)
|
|
:is-selected (= (:id item) (:id project))}])]
|
|
[:div {:class (stl/css :sidebar-empty-placeholder)}
|
|
pin-icon
|
|
[:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]]
|
|
[:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]]))
|
|
|
|
(mf/defc help-learning-menu*
|
|
{::mf/private true}
|
|
[{:keys [on-close on-click on-pointer-enter on-pointer-leave]}]
|
|
(let [handle-click-url
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [url (-> (dom/get-current-target event)
|
|
(dom/get-data "url"))
|
|
eventname (-> (dom/get-current-target event)
|
|
(dom/get-data "eventname"))]
|
|
(st/emit! (ev/event {::ev/name eventname
|
|
::ev/origin "menu:in-app"}))
|
|
(dom/open-new-window url))))
|
|
|
|
handle-feedback-click
|
|
(mf/use-fn #(on-click :settings-feedback %))]
|
|
|
|
[:> dropdown-menu* {:show true
|
|
:class (stl/css :sub-menu :help-learning)
|
|
:on-close on-close
|
|
:on-pointer-enter on-pointer-enter
|
|
:on-pointer-leave on-pointer-leave}
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://help.penpot.app"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-help-center-click"}
|
|
(tr "labels.help-center")]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://penpot.app/learning-center"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-learning-center-click"}
|
|
(tr "labels.learning-center")]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://penpot.app/penpothub"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-penpot-hub-click"}
|
|
(tr "labels.penpot-hub")]
|
|
|
|
(when (contains? cf/flags :user-feedback)
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:on-click handle-feedback-click}
|
|
(tr "labels.give-feedback")])]))
|
|
|
|
(mf/defc community-contributions-menu*
|
|
{::mf/private true}
|
|
[{:keys [on-close on-pointer-enter on-pointer-leave]}]
|
|
(let [handle-click-url
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [url (-> (dom/get-current-target event)
|
|
(dom/get-data "url"))
|
|
eventname (-> (dom/get-current-target event)
|
|
(dom/get-data "eventname"))]
|
|
(st/emit! (ev/event {::ev/name eventname
|
|
::ev/origin "menu:in-app"}))
|
|
(dom/open-new-window url))))]
|
|
|
|
[:> dropdown-menu* {:show true
|
|
:class (stl/css :sub-menu :community)
|
|
:on-close on-close
|
|
:on-pointer-enter on-pointer-enter
|
|
:on-pointer-leave on-pointer-leave}
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://github.com/penpot/penpot"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-github-repository-click"}
|
|
(tr "labels.github-repo")]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://community.penpot.app"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-community-click"}
|
|
(tr "labels.community")]]))
|
|
|
|
(mf/defc about-penpot-menu*
|
|
{::mf/private true}
|
|
[{:keys [on-close on-close-profile on-pointer-enter on-pointer-leave]}]
|
|
(let [version cf/version
|
|
checking* (mf/use-state false)
|
|
checking? (deref checking*)
|
|
|
|
show-release-notes
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(st/emit! (ev/event {::ev/name "show-release-notes" :version (:main version)}))
|
|
(if (and (kbd/alt? event) (kbd/mod? event))
|
|
(st/emit! (modal/show {:type :onboarding}))
|
|
(st/emit! (modal/show {:type :release-notes :version (:main version)})))))
|
|
|
|
handle-click-url
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [url (-> (dom/get-current-target event)
|
|
(dom/get-data "url"))
|
|
eventname (-> (dom/get-current-target event)
|
|
(dom/get-data "eventname"))]
|
|
(st/emit! (ev/event {::ev/name eventname
|
|
::ev/origin "menu:in-app"}))
|
|
(dom/open-new-window url))))
|
|
|
|
check-for-updates
|
|
(mf/use-fn
|
|
(mf/deps on-close-profile version)
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(when-not @checking*
|
|
(st/emit! (ev/event {::ev/name "check-for-updates"
|
|
::ev/origin "menu:in-app"
|
|
:version (:base version)}))
|
|
(dcu/check-for-updates!
|
|
(:base version)
|
|
{:on-start #(reset! checking* true)
|
|
:on-finish #(do (reset! checking* false)
|
|
(on-close-profile))}))))]
|
|
|
|
[:> dropdown-menu* {:show true
|
|
:class (stl/css :sub-menu :about)
|
|
:on-close on-close
|
|
:on-pointer-enter on-pointer-enter
|
|
:on-pointer-leave on-pointer-leave}
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:on-click show-release-notes}
|
|
(tr "labels.version-notes" (:base version))]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://github.com/penpot/penpot/blob/develop/CHANGES.md"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-changelog-click"}
|
|
(tr "labels.penpot-changelog")]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
|
|
:data-url "https://penpot.app/terms"
|
|
:on-click handle-click-url
|
|
:data-eventname "explore-terms-service-click"}
|
|
(tr "auth.terms-of-service")]
|
|
(when-not (contains? cf/flags :air-gapped-conf)
|
|
[:*
|
|
[:hr {:role "separator" :class (stl/css :submenu-separator)}]
|
|
[:> dropdown-menu-item* {:class (stl/css-case :submenu-item true
|
|
:checking checking?)
|
|
:aria-disabled checking?
|
|
:can-focus (not checking?)
|
|
:on-click check-for-updates}
|
|
(if checking?
|
|
(tr "labels.checking-for-updates")
|
|
(tr "labels.check-for-updates"))
|
|
(when checking?
|
|
[:> icon* {:icon-id i/reload
|
|
:class (stl/css :checking-icon)
|
|
:size "s"}])]])]))
|
|
|
|
(mf/defc profile-section*
|
|
[{:keys [profile team]}]
|
|
(let [teams (mf/deref refs/teams)
|
|
show-profile-menu* (mf/use-state false)
|
|
show-profile-menu? (deref show-profile-menu*)
|
|
sub-menu* (mf/use-state false)
|
|
sub-menu (deref sub-menu*)
|
|
|
|
;; Tracks whether the pointer is over an expandable option or
|
|
;; its floating submenu, so the submenu survives the gap
|
|
;; between them while the pointer travels across.
|
|
hovering?* (mf/use-ref false)
|
|
version (:base cf/version)
|
|
|
|
close-sub-menu
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(reset! sub-menu* nil)))
|
|
|
|
photo
|
|
(cf/resolve-profile-photo-url profile)
|
|
|
|
on-click
|
|
(mf/use-fn
|
|
(fn [section event]
|
|
(dom/stop-propagation event)
|
|
(reset! show-profile-menu* false)
|
|
(if (keyword? section)
|
|
(st/emit! (rt/nav section))
|
|
(st/emit! section))))
|
|
|
|
show-comments* (mf/use-state false)
|
|
show-comments? @show-comments*
|
|
|
|
handle-hide-comments
|
|
(mf/use-fn
|
|
(fn []
|
|
(reset! show-comments* false)))
|
|
|
|
handle-show-comments
|
|
(mf/use-fn
|
|
(fn []
|
|
(reset! show-comments* true)))
|
|
|
|
handle-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(swap! show-profile-menu* not)))
|
|
|
|
handle-key-down
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(when (kbd/enter? event)
|
|
(reset! show-profile-menu* true))))
|
|
|
|
on-close
|
|
(mf/use-fn #(reset! show-profile-menu* false))
|
|
|
|
handle-logout-click
|
|
(mf/use-fn
|
|
#(on-click (da/logout) %))
|
|
|
|
handle-set-profile
|
|
(mf/use-fn
|
|
#(on-click :settings-profile %))
|
|
|
|
on-menu-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(let [menu (-> (dom/get-current-target event)
|
|
(dom/get-data "testid")
|
|
(keyword))]
|
|
(reset! sub-menu* menu))))
|
|
|
|
on-menu-pointer-enter
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(mf/set-ref-val! hovering?* true)
|
|
(on-menu-click event)))
|
|
|
|
on-menu-pointer-leave
|
|
(mf/use-fn
|
|
(fn [_]
|
|
(mf/set-ref-val! hovering?* false)
|
|
(ts/schedule 200 #(when-not (mf/ref-val hovering?*)
|
|
(reset! sub-menu* nil)))))
|
|
|
|
on-sub-menu-pointer-enter
|
|
(mf/use-fn
|
|
(fn [_]
|
|
(mf/set-ref-val! hovering?* true)))]
|
|
|
|
(mf/with-effect [teams]
|
|
(when (and (contains? cf/flags :admin-console)
|
|
(empty? teams))
|
|
(st/emit! (dtm/fetch-teams))))
|
|
|
|
(mf/with-effect [show-profile-menu?]
|
|
(when-not show-profile-menu?
|
|
(reset! sub-menu* nil)))
|
|
|
|
[:*
|
|
(if (contains? cf/flags :admin-console)
|
|
[:*
|
|
[:> nitrate-sidebar* {:profile profile :teams teams}]
|
|
[:> nitrate-current-plan* {:profile profile}]]
|
|
(when (contains? cf/flags :subscriptions)
|
|
(if (show-subscription-dashboard-banner? profile)
|
|
[:> dashboard-cta* {:profile profile}]
|
|
[:> subscription-sidebar* {:profile profile}])))
|
|
|
|
|
|
(when (and team profile)
|
|
[:& comments-section
|
|
{:profile profile
|
|
:team team
|
|
:show? show-comments?
|
|
:on-show-comments handle-show-comments
|
|
:on-hide-comments handle-hide-comments}])
|
|
|
|
[:div {:class (stl/css :profile-section)}
|
|
[:button {:class (stl/css :profile)
|
|
:tab-index "0"
|
|
:on-click handle-click
|
|
:on-key-down handle-key-down
|
|
:data-testid "profile-btn"}
|
|
[:img {:src photo
|
|
:class (stl/css :profile-img)
|
|
:alt (:fullname profile)}]
|
|
[:span {:class (stl/css :profile-fullname)} (:fullname profile)]]
|
|
|
|
[:> dropdown-menu* {:on-close on-close
|
|
:show show-profile-menu?
|
|
:id "profile-menu"
|
|
:class (stl/css :profile-dropdown)}
|
|
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
|
|
:on-click handle-set-profile
|
|
:data-testid "profile-profile-opt"}
|
|
(tr "labels.your-account")]
|
|
|
|
[:li {:class (stl/css :profile-separator)}]
|
|
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true)
|
|
:on-click on-menu-click
|
|
:on-key-down (fn [event]
|
|
(when (kbd/enter? event)
|
|
(on-menu-click event)))
|
|
:on-pointer-enter on-menu-pointer-enter
|
|
:on-pointer-leave on-menu-pointer-leave
|
|
:data-testid "help-learning"
|
|
:id "help-learning"}
|
|
[:span {:class (stl/css :item-name)} (tr "labels.help-learning")]
|
|
[:> icon* {:icon-id i/arrow :class (stl/css :open-arrow)}]]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true)
|
|
:on-click on-menu-click
|
|
:on-key-down (fn [event]
|
|
(when (kbd/enter? event)
|
|
(on-menu-click event)))
|
|
:on-pointer-enter on-menu-pointer-enter
|
|
:on-pointer-leave on-menu-pointer-leave
|
|
:data-testid "community-contributions"
|
|
:id "community-contributions"}
|
|
[:span {:class (stl/css :item-name)} (tr "labels.community-contributions")]
|
|
[:> icon* {:icon-id i/arrow :class (stl/css :open-arrow)}]]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true)
|
|
:on-click on-menu-click
|
|
:on-key-down (fn [event]
|
|
(when (kbd/enter? event)
|
|
(on-menu-click event)))
|
|
:on-pointer-enter on-menu-pointer-enter
|
|
:on-pointer-leave on-menu-pointer-leave
|
|
:data-testid "about-penpot"
|
|
:id "about-penpot"}
|
|
|
|
[:div {:class (stl/css :about-penpot)}
|
|
[:span {:class (stl/css :item-name)} (tr "labels.about-penpot")]
|
|
[:span {:class (stl/css :menu-version) :title version} version]]
|
|
[:> icon* {:icon-id i/arrow :class (stl/css :open-arrow)}]]
|
|
|
|
[:li {:class (stl/css :profile-separator)}]
|
|
|
|
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item :item-with-icon)
|
|
:on-click handle-logout-click
|
|
:data-testid "logout-profile-opt"}
|
|
exit-icon
|
|
(tr "labels.logout")]]
|
|
|
|
(when (and team profile)
|
|
[:> comments-icon*
|
|
{:profile profile
|
|
:on-show-comments handle-show-comments}])]
|
|
|
|
(when show-profile-menu?
|
|
(case sub-menu
|
|
:help-learning
|
|
[:> help-learning-menu* {:on-close close-sub-menu
|
|
:on-click on-click
|
|
:on-pointer-enter on-sub-menu-pointer-enter
|
|
:on-pointer-leave on-menu-pointer-leave}]
|
|
|
|
:community-contributions
|
|
[:> community-contributions-menu* {:on-close close-sub-menu
|
|
:on-pointer-enter on-sub-menu-pointer-enter
|
|
:on-pointer-leave on-menu-pointer-leave}]
|
|
|
|
:about-penpot
|
|
[:> about-penpot-menu* {:on-close close-sub-menu
|
|
:on-close-profile on-close
|
|
:on-pointer-enter on-sub-menu-pointer-enter
|
|
:on-pointer-leave on-menu-pointer-leave}]
|
|
nil))]))
|
|
|
|
(mf/defc sidebar*
|
|
{::mf/wrap [mf/memo]}
|
|
[{:keys [team profile] :as props}]
|
|
[:nav {:class (stl/css :dashboard-sidebar) :data-testid "dashboard-sidebar"}
|
|
[:> sidebar-content* props]
|
|
[:> profile-section*
|
|
{:profile profile
|
|
:team team}]])
|