diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 097b31385..e2ae29ae7 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -359,17 +359,17 @@ input:-webkit-autofill { font-size: 12px; } -.sidebar .tippy-box[data-placement^='right'] > .tippy-arrow { +.sidebar.tippy-box[data-placement^='right'] > .tippy-arrow { width: 12px; height: 12px; } -.sidebar .tippy-box[data-placement^='right'] > .tippy-arrow:before { - border-right: 8px solid var(--ui-gray-9); +.sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--ui-blue-9); border-width: 6px 8px 6px 0; } -[theme='dark'] .sidebar .tippy-box[data-placement^='right'] > .tippy-arrow:before { +[theme='dark'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { border-right: 8px solid var(--ui-gray-true-9); } diff --git a/app/react/sidebar/DockerSidebar.tsx b/app/react/sidebar/DockerSidebar.tsx index 68a89c16e..b82256682 100644 --- a/app/react/sidebar/DockerSidebar.tsx +++ b/app/react/sidebar/DockerSidebar.tsx @@ -22,6 +22,7 @@ import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; import { SidebarItem } from './SidebarItem'; import { DashboardLink } from './items/DashboardLink'; import { VolumesLink } from './items/VolumesLink'; +import { SidebarParent } from './SidebarItem/SidebarParent'; interface Props { environmentId: EnvironmentId; @@ -72,21 +73,29 @@ export function DockerSidebar({ environmentId, environment }: Props) { platformPath="docker" data-cy="dockerSidebar-dashboard" /> - - + - + {areStacksVisible && ( )} - + + - + ); } diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.module.css b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.module.css deleted file mode 100644 index 75b7bb557..000000000 --- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.root { - display: block; - margin: -5px auto -8px auto; - padding-bottom: 5px; -} diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx index 2864b7cf4..d270f4944 100644 --- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx @@ -1,37 +1,53 @@ -import clsx from 'clsx'; import { useState } from 'react'; import { createPortal } from 'react-dom'; import { Terminal } from 'lucide-react'; +import clsx from 'clsx'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { useAnalytics } from '@/react/hooks/useAnalytics'; import { Button } from '@@/buttons'; -import { Icon } from '@@/Icon'; + +import { useSidebarState } from '../../useSidebarState'; +import { SidebarTooltip } from '../../SidebarItem/SidebarTooltip'; import { KubeCtlShell } from './KubectlShell'; -import styles from './KubectlShellButton.module.css'; interface Props { environmentId: EnvironmentId; } export function KubectlShellButton({ environmentId }: Props) { + const { isOpen: isSidebarOpen } = useSidebarState(); + const [open, setOpen] = useState(false); const { trackEvent } = useAnalytics(); + + const button = ( + + ); + return ( <> - - + {!isSidebarOpen && ( + Kubectl Shell + } + > + {button} + + )} + {isSidebarOpen && button} {open && createPortal( - {isOpen && ( -
- -
- )} +
+ +
- + pathOptions={{ includePaths: ['kubernetes.ingresses'] }} + data-cy="k8sSidebar-networking" + > + - + + - + @@ -115,17 +133,35 @@ export function KubernetesSidebar({ environmentId }: Props) { to="kubernetes.cluster.securityConstraint" params={{ endpointId: environmentId }} label="Security constraints" + isSubMenu data-cy="k8sSidebar-securityConstraints" /> + {isBE && ( + + + + )} + - + ); } diff --git a/app/react/sidebar/SettingsSidebar.tsx b/app/react/sidebar/SettingsSidebar.tsx index 6f3e27e9a..16bf16852 100644 --- a/app/react/sidebar/SettingsSidebar.tsx +++ b/app/react/sidebar/SettingsSidebar.tsx @@ -13,6 +13,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { SidebarItem } from './SidebarItem'; import { SidebarSection } from './SidebarSection'; +import { SidebarParent } from './SidebarItem/SidebarParent'; interface Props { isAdmin: boolean; @@ -30,15 +31,23 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) { return ( {showUsersSection && ( - + @@ -46,38 +55,56 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) { )} - + )} {isAdmin && ( <> - + {isBE && ( )} - + )} - + - + )} {isAdmin && ( - + {!window.ddExtension && ( )} {isBE && ( )} @@ -151,12 +201,12 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) { } target="_blank" rel="noreferrer" - className="flex h-8 items-center rounded px-3" + className="hover:!underline focus:no-underline text-sm flex h-8 w-full items-center rounded px-3 transition-colors duration-200 hover:bg-blue-5/20 be:hover:bg-gray-5/20 th-dark:hover:bg-gray-true-5/20" > Help / About - + )} ); diff --git a/app/react/sidebar/Sidebar.module.css b/app/react/sidebar/Sidebar.module.css index 0ced98ff6..db1005820 100644 --- a/app/react/sidebar/Sidebar.module.css +++ b/app/react/sidebar/Sidebar.module.css @@ -16,7 +16,8 @@ :global(#page-wrapper) { --sidebar-width: 300px; - --sidebar-closed-width: 75px; + /* 32px for collapsed items + 20px padding on each side */ + --sidebar-closed-width: 72px; } :global(#page-wrapper.open) .root { @@ -45,3 +46,19 @@ margin: 0; list-style: none; } + +/* make the scrollbar track background transparent */ +.nav-list-container::-webkit-scrollbar { + @apply !w-4 !h-4 bg-transparent; +} + +.nav-list-container::-webkit-scrollbar-thumb { + @apply !bg-gray-3/40 hover:!bg-gray-3/50 th-dark:!bg-gray-3/40 th-dark:hover:!bg-gray-3/50 !rounded-full; + /* adding this border gives some gap between the right edge of the box and the scrollbar thumb for webkit browsers, + while giving right no right gap from the scrollbar to the box edge for non-webkit browsers (firefox) */ + @apply !border-4 !border-solid !border-transparent bg-clip-content; +} + +.nav-list-container::-webkit-scrollbar-track { + @apply !bg-transparent; +} diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx index 3139ceb21..892e6bfdf 100644 --- a/app/react/sidebar/Sidebar.tsx +++ b/app/react/sidebar/Sidebar.tsx @@ -12,12 +12,22 @@ import { SettingsSidebar } from './SettingsSidebar'; import { SidebarItem } from './SidebarItem'; import { Footer } from './Footer'; import { Header } from './Header'; -import { SidebarProvider } from './useSidebarState'; +import { SidebarProvider, useSidebarState } from './useSidebarState'; import { UpgradeBEBannerWrapper } from './UpgradeBEBanner'; export function Sidebar() { + return ( + /* in the future (when we remove r2a) this should wrap the whole app - to change root styles */ + + + + ); +} + +function InnerSidebar() { const { isAdmin, user } = useUser(); const isTeamLeader = useIsTeamLeader(user) as boolean; + const { isOpen } = useSidebarState(); const settingsQuery = usePublicSettings(); @@ -28,37 +38,41 @@ export function Sidebar() { const { LogoURL } = settingsQuery.data; return ( - /* in the future (when we remove r2a) this should wrap the whole app - to change root styles */ - -
- -
-
+
    + + + {isAdmin && } + +
+ +
+
+
+ + ); } diff --git a/app/react/sidebar/SidebarItem/SidebarItem.tsx b/app/react/sidebar/SidebarItem/SidebarItem.tsx index 5518fb481..5e16a4a7b 100644 --- a/app/react/sidebar/SidebarItem/SidebarItem.tsx +++ b/app/react/sidebar/SidebarItem/SidebarItem.tsx @@ -1,52 +1,71 @@ -import { ReactNode } from 'react'; -import { Icon } from 'lucide-react'; +import { Icon as IconTest } from 'lucide-react'; +import clsx from 'clsx'; import { AutomationTestingProps } from '@/types'; +import { Icon } from '@@/Icon'; + +import { useSidebarState } from '../useSidebarState'; + import { Wrapper } from './Wrapper'; -import { Menu } from './Menu'; -import { Head } from './Head'; -import { getPathsForChildren } from './utils'; +import { SidebarTooltip } from './SidebarTooltip'; +import { useSidebarSrefActive } from './useSidebarSrefActive'; interface Props extends AutomationTestingProps { - icon?: Icon; + icon?: IconTest; to: string; params?: object; label: string; - children?: ReactNode; - openOnPaths?: string[]; + isSubMenu?: boolean; + ignorePaths?: string[]; + includePaths?: string[]; } export function SidebarItem({ - children, icon, to, params, label, - openOnPaths = [], + isSubMenu = false, + ignorePaths = [], + includePaths = [], 'data-cy': dataCy, }: Props) { - const childrenPath = getPathsForChildren(children); - const head = ( - + const { isOpen } = useSidebarState(); + const anchorProps = useSidebarSrefActive(to, undefined, params, undefined, { + ignorePaths, + includePaths, + }); + + const anchor = ( + + + {!!icon && svg]:w-4')} />} + {(isOpen || isSubMenu) && {label}} + + ); + if (isOpen || isSubMenu) return anchor; + return ( - - {children ? ( - - {children} - - ) : ( - head - )} - + {label}}> + {anchor} + ); } diff --git a/app/react/sidebar/SidebarItem/SidebarParent.tsx b/app/react/sidebar/SidebarItem/SidebarParent.tsx new file mode 100644 index 000000000..723ba0705 --- /dev/null +++ b/app/react/sidebar/SidebarItem/SidebarParent.tsx @@ -0,0 +1,135 @@ +import clsx from 'clsx'; +import { ChevronDown } from 'lucide-react'; +import { PropsWithChildren, useState } from 'react'; + +import { Icon } from '@@/Icon'; +import { Link } from '@@/Link'; + +import { useSidebarState } from '../useSidebarState'; + +import { Wrapper } from './Wrapper'; +import { PathOptions, useSidebarSrefActive } from './useSidebarSrefActive'; +import { SidebarTooltip } from './SidebarTooltip'; + +type Props = { + label: string; + icon: React.ReactNode; + to: string; + 'data-cy': string; + pathOptions?: PathOptions; + params?: object; +}; + +export function SidebarParent({ + children, + icon, + label: title, + to, + params, + pathOptions, + 'data-cy': dataCy, +}: PropsWithChildren) { + const anchorProps = useSidebarSrefActive( + to, + undefined, + params, + {}, + pathOptions + ); + + const hasActiveChild = !!anchorProps.className; + + const { isOpen: isSidebarOpen } = useSidebarState(); + + const [isExpanded, setIsExpanded] = useState(hasActiveChild); + + const parentItem = ( + +
+ + {isSidebarOpen && ( + + )} +
+
+ ); + + const childList = ( +
    + {children} +
+ ); + + if (isSidebarOpen) + return ( + <> + {parentItem} + {childList} + + ); + + return ( + +
  • + {title} +
  • +
    + {children} +
    + + } + > + {parentItem} +
    + ); +} diff --git a/app/react/sidebar/SidebarItem/SidebarTooltip.tsx b/app/react/sidebar/SidebarItem/SidebarTooltip.tsx new file mode 100644 index 000000000..8ccd9e334 --- /dev/null +++ b/app/react/sidebar/SidebarItem/SidebarTooltip.tsx @@ -0,0 +1,24 @@ +import Tippy, { TippyProps } from '@tippyjs/react'; + +type Props = { + content: React.ReactNode; + children?: TippyProps['children']; +}; + +export function SidebarTooltip({ children, content }: Props) { + return ( + + {children} + + ); +} diff --git a/app/react/sidebar/SidebarItem/useSidebarSrefActive.tsx b/app/react/sidebar/SidebarItem/useSidebarSrefActive.tsx new file mode 100644 index 000000000..8f329948e --- /dev/null +++ b/app/react/sidebar/SidebarItem/useSidebarSrefActive.tsx @@ -0,0 +1,51 @@ +import { + TransitionOptions, + useCurrentStateAndParams, + useSrefActive, +} from '@uirouter/react'; + +export type PathOptions = { + ignorePaths?: string[]; + includePaths?: string[]; +}; + +/** + * Extends useSrefActive by ignoring or including paths and updating the classNames field returned when a child route is active. + * @param to The route to match + * @param activeClassName The active class names to return + * @param params The route params + * @param options The transition options + * @param pathOptions The paths to ignore/include + */ +export function useSidebarSrefActive( + to: string, + // default values are the classes used in the sidebar for an active item + activeClassName: string = 'bg-blue-5/25 be:bg-gray-5/25 th-dark:bg-gray-true-5/25', + params: Partial> = {}, + options: TransitionOptions = {}, + pathOptions: PathOptions = { + ignorePaths: [], + includePaths: [], + } +) { + const { state: { name: stateName = '' } = {} } = useCurrentStateAndParams(); + const anchorProps = useSrefActive(to, params || {}, activeClassName, options); + + // overwrite the className to '' if the the current route is in ignorePaths + const isIgnorePathInRoute = pathOptions.ignorePaths?.some((path) => + stateName.includes(path) + ); + if (isIgnorePathInRoute) { + return { ...anchorProps, className: '' }; + } + + // overwrite the className to activeClassName if the the current route is in includePaths + const isIncludePathInRoute = pathOptions.includePaths?.some((path) => + stateName.includes(path) + ); + if (isIncludePathInRoute) { + return { ...anchorProps, className: activeClassName }; + } + + return anchorProps; +} diff --git a/app/react/sidebar/SidebarItem/utils.ts b/app/react/sidebar/SidebarItem/utils.ts index f90e0a909..fd01961e0 100644 --- a/app/react/sidebar/SidebarItem/utils.ts +++ b/app/react/sidebar/SidebarItem/utils.ts @@ -9,10 +9,6 @@ function isReactElement(element: ReactNode): element is ReactElement { ); } -export function getPathsForChildren(children: ReactNode): string[] { - return Children.map(children, (child) => getPaths(child, []))?.flat() || []; -} - export function getPaths(element: ReactNode, paths: string[]): string[] { if (!isReactElement(element)) { return paths;