feat(sidebar): update menu structure [EE-5666] (#10418)

pull/10451/head
Ali 1 year ago committed by GitHub
parent b468070945
commit a0dbabcc5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -359,17 +359,17 @@ input:-webkit-autofill {
font-size: 12px; font-size: 12px;
} }
.sidebar .tippy-box[data-placement^='right'] > .tippy-arrow { .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
.sidebar .tippy-box[data-placement^='right'] > .tippy-arrow:before { .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before {
border-right: 8px solid var(--ui-gray-9); border-right: 8px solid var(--ui-blue-9);
border-width: 6px 8px 6px 0; 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); border-right: 8px solid var(--ui-gray-true-9);
} }

@ -22,6 +22,7 @@ import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { SidebarItem } from './SidebarItem'; import { SidebarItem } from './SidebarItem';
import { DashboardLink } from './items/DashboardLink'; import { DashboardLink } from './items/DashboardLink';
import { VolumesLink } from './items/VolumesLink'; import { VolumesLink } from './items/VolumesLink';
import { SidebarParent } from './SidebarItem/SidebarParent';
interface Props { interface Props {
environmentId: EnvironmentId; environmentId: EnvironmentId;
@ -72,21 +73,29 @@ export function DockerSidebar({ environmentId, environment }: Props) {
platformPath="docker" platformPath="docker"
data-cy="dockerSidebar-dashboard" data-cy="dockerSidebar-dashboard"
/> />
<SidebarParent
<SidebarItem
label="App Templates"
icon={Edit} icon={Edit}
label="Templates"
to="docker.templates" to="docker.templates"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
data-cy="portainerSidebar-appTemplates" data-cy="portainerSidebar-templates"
> >
<SidebarItem <SidebarItem
label="Custom Templates" label="Application"
to="docker.templates"
ignorePaths={['docker.templates.custom']}
params={{ endpointId: environmentId }}
isSubMenu
data-cy="portainerSidebar-appTemplates"
/>
<SidebarItem
label="Custom"
to="docker.templates.custom" to="docker.templates.custom"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
isSubMenu
data-cy="dockerSidebar-customTemplates" data-cy="dockerSidebar-customTemplates"
/> />
</SidebarItem> </SidebarParent>
{areStacksVisible && ( {areStacksVisible && (
<SidebarItem <SidebarItem
@ -168,31 +177,42 @@ export function DockerSidebar({ environmentId, environment }: Props) {
/> />
)} )}
<SidebarItem <SidebarParent
label={setupSubMenuProps.label} label={setupSubMenuProps.label}
icon={setupSubMenuProps.icon} icon={setupSubMenuProps.icon}
to={setupSubMenuProps.to} to={setupSubMenuProps.to}
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
data-cy={setupSubMenuProps.dataCy} data-cy="portainerSidebar-host"
> >
<SidebarItem
label="Details"
isSubMenu
to={setupSubMenuProps.to}
params={{ endpointId: environmentId }}
ignorePaths={[featSubMenuTo, registrySubMenuTo]}
data-cy={setupSubMenuProps.dataCy}
/>
<Authorized <Authorized
authorizations="PortainerEndpointUpdateSettings" authorizations="PortainerEndpointUpdateSettings"
adminOnlyCE adminOnlyCE
environmentId={environmentId} environmentId={environmentId}
> >
<SidebarItem <SidebarItem
label="Setup"
isSubMenu
to={featSubMenuTo} to={featSubMenuTo}
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Setup"
/> />
</Authorized> </Authorized>
<SidebarItem <SidebarItem
label="Registries"
isSubMenu
to={registrySubMenuTo} to={registrySubMenuTo}
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Registries"
/> />
</SidebarItem> </SidebarParent>
</> </>
); );
} }

@ -1,5 +0,0 @@
.root {
display: block;
margin: -5px auto -8px auto;
padding-bottom: 5px;
}

@ -1,37 +1,53 @@
import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Terminal } from 'lucide-react'; import { Terminal } from 'lucide-react';
import clsx from 'clsx';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { useAnalytics } from '@/react/hooks/useAnalytics'; import { useAnalytics } from '@/react/hooks/useAnalytics';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { useSidebarState } from '../../useSidebarState';
import { SidebarTooltip } from '../../SidebarItem/SidebarTooltip';
import { KubeCtlShell } from './KubectlShell'; import { KubeCtlShell } from './KubectlShell';
import styles from './KubectlShellButton.module.css';
interface Props { interface Props {
environmentId: EnvironmentId; environmentId: EnvironmentId;
} }
export function KubectlShellButton({ environmentId }: Props) { export function KubectlShellButton({ environmentId }: Props) {
const { isOpen: isSidebarOpen } = useSidebarState();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const button = (
<Button
color="primary"
size="small"
disabled={open}
data-cy="k8sSidebar-shellButton"
onClick={() => handleOpen()}
className={clsx('sidebar', !isSidebarOpen && '!p-1')}
icon={Terminal}
>
{isSidebarOpen ? 'kubectl shell' : ''}
</Button>
);
return ( return (
<> <>
<Button {!isSidebarOpen && (
color="primary" <SidebarTooltip
size="small" content={
disabled={open} <span className="text-sm whitespace-nowrap">Kubectl Shell</span>
data-cy="k8sSidebar-shellButton" }
onClick={() => handleOpen()} >
className={clsx(styles.root, '!flex')} <span className="flex w-full justify-center">{button}</span>
> </SidebarTooltip>
<Icon icon={Terminal} className="vertical-center" size="md" /> kubectl )}
shell {isSidebarOpen && button}
</Button>
{open && {open &&
createPortal( createPortal(
<KubeCtlShell <KubeCtlShell

@ -1,13 +1,13 @@
import { Box, Edit, Layers, Lock, Server, Shuffle } from 'lucide-react'; import { Box, Edit, Layers, Lock, Network, Server } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import Route from '@/assets/ico/route.svg?c'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { DashboardLink } from '../items/DashboardLink'; import { DashboardLink } from '../items/DashboardLink';
import { SidebarItem } from '../SidebarItem'; import { SidebarItem } from '../SidebarItem';
import { VolumesLink } from '../items/VolumesLink'; import { VolumesLink } from '../items/VolumesLink';
import { useSidebarState } from '../useSidebarState'; import { SidebarParent } from '../SidebarItem/SidebarParent';
import { KubectlShellButton } from './KubectlShell'; import { KubectlShellButton } from './KubectlShell';
@ -16,15 +16,11 @@ interface Props {
} }
export function KubernetesSidebar({ environmentId }: Props) { export function KubernetesSidebar({ environmentId }: Props) {
const { isOpen } = useSidebarState();
return ( return (
<> <>
{isOpen && ( <div className="w-full flex mb-2 justify-center -mt-2">
<div className="mb-3"> <KubectlShellButton environmentId={environmentId} />
<KubectlShellButton environmentId={environmentId} /> </div>
</div>
)}
<DashboardLink <DashboardLink
environmentId={environmentId} environmentId={environmentId}
@ -56,21 +52,30 @@ export function KubernetesSidebar({ environmentId }: Props) {
data-cy="k8sSidebar-applications" data-cy="k8sSidebar-applications"
/> />
<SidebarItem <SidebarParent
label="Networking"
icon={Network}
to="kubernetes.services" to="kubernetes.services"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Services" pathOptions={{ includePaths: ['kubernetes.ingresses'] }}
data-cy="k8sSidebar-services" data-cy="k8sSidebar-networking"
icon={Shuffle} >
/> <SidebarItem
to="kubernetes.services"
params={{ endpointId: environmentId }}
label="Services"
isSubMenu
data-cy="k8sSidebar-services"
/>
<SidebarItem <SidebarItem
to="kubernetes.ingresses" to="kubernetes.ingresses"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Ingresses" label="Ingresses"
data-cy="k8sSidebar-ingresses" isSubMenu
icon={Route} data-cy="k8sSidebar-ingresses"
/> />
</SidebarParent>
<SidebarItem <SidebarItem
to="kubernetes.configurations" to="kubernetes.configurations"
@ -86,13 +91,25 @@ export function KubernetesSidebar({ environmentId }: Props) {
data-cy="k8sSidebar-volumes" data-cy="k8sSidebar-volumes"
/> />
<SidebarItem <SidebarParent
label="Cluster" label="Cluster"
to="kubernetes.cluster"
icon={Server} icon={Server}
to="kubernetes.cluster"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
pathOptions={{ includePaths: ['kubernetes.registries'] }}
data-cy="k8sSidebar-cluster" data-cy="k8sSidebar-cluster"
> >
<SidebarItem
label="Details"
to="kubernetes.cluster"
ignorePaths={[
'kubernetes.cluster.setup',
'kubernetes.cluster.securityConstraint',
]}
params={{ endpointId: environmentId }}
isSubMenu
data-cy="k8sSidebar-clusterDetails"
/>
<Authorized <Authorized
authorizations="K8sClusterSetupRW" authorizations="K8sClusterSetupRW"
adminOnlyCE adminOnlyCE
@ -102,6 +119,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
to="kubernetes.cluster.setup" to="kubernetes.cluster.setup"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Setup" label="Setup"
isSubMenu
data-cy="k8sSidebar-setup" data-cy="k8sSidebar-setup"
/> />
</Authorized> </Authorized>
@ -115,17 +133,35 @@ export function KubernetesSidebar({ environmentId }: Props) {
to="kubernetes.cluster.securityConstraint" to="kubernetes.cluster.securityConstraint"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Security constraints" label="Security constraints"
isSubMenu
data-cy="k8sSidebar-securityConstraints" data-cy="k8sSidebar-securityConstraints"
/> />
</Authorized> </Authorized>
{isBE && (
<Authorized
authorizations="K8sClusterSetupRW"
adminOnlyCE
environmentId={environmentId}
>
<SidebarItem
to="kubernetes.cluster.securityConstraint"
params={{ endpointId: environmentId }}
label="Security Constraints"
isSubMenu
data-cy="k8sSidebar-securityConstraints"
/>
</Authorized>
)}
<SidebarItem <SidebarItem
to="kubernetes.registries" to="kubernetes.registries"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
label="Registries" label="Registries"
isSubMenu
data-cy="k8sSidebar-registries" data-cy="k8sSidebar-registries"
/> />
</SidebarItem> </SidebarParent>
</> </>
); );
} }

@ -13,6 +13,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { SidebarItem } from './SidebarItem'; import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection'; import { SidebarSection } from './SidebarSection';
import { SidebarParent } from './SidebarItem/SidebarParent';
interface Props { interface Props {
isAdmin: boolean; isAdmin: boolean;
@ -30,15 +31,23 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
return ( return (
<SidebarSection title="Settings"> <SidebarSection title="Settings">
{showUsersSection && ( {showUsersSection && (
<SidebarItem <SidebarParent
to="portainer.users" label="User-related"
label="Users"
icon={Users} icon={Users}
data-cy="portainerSidebar-users" to="portainer.users"
pathOptions={{ includePaths: ['portainer.teams', 'portainer.roles'] }}
data-cy="portainerSidebar-userRelated"
> >
<SidebarItem
to="portainer.users"
label="Users"
isSubMenu
data-cy="portainerSidebar-users"
/>
<SidebarItem <SidebarItem
to="portainer.teams" to="portainer.teams"
label="Teams" label="Teams"
isSubMenu
data-cy="portainerSidebar-teams" data-cy="portainerSidebar-teams"
/> />
@ -46,38 +55,56 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
<SidebarItem <SidebarItem
to="portainer.roles" to="portainer.roles"
label="Roles" label="Roles"
isSubMenu
data-cy="portainerSidebar-roles" data-cy="portainerSidebar-roles"
/> />
)} )}
</SidebarItem> </SidebarParent>
)} )}
{isAdmin && ( {isAdmin && (
<> <>
<SidebarItem <SidebarParent
label="Environments" label="Environment-related"
to="portainer.endpoints"
icon={HardDrive} icon={HardDrive}
openOnPaths={['portainer.wizard.endpoints']} to="portainer.endpoints"
data-cy="portainerSidebar-environments" pathOptions={{
includePaths: [
'portainer.wizard.endpoints',
'portainer.groups',
'portainer.tags',
],
}}
data-cy="k8sSidebar-networking"
> >
<SidebarItem
label="Environments"
to="portainer.endpoints"
ignorePaths={['portainer.endpoints.updateSchedules']}
includePaths={['portainer.wizard.endpoints']}
isSubMenu
data-cy="portainerSidebar-environments"
/>
<SidebarItem <SidebarItem
to="portainer.groups" to="portainer.groups"
label="Groups" label="Groups"
isSubMenu
data-cy="portainerSidebar-environmentGroups" data-cy="portainerSidebar-environmentGroups"
/> />
<SidebarItem <SidebarItem
to="portainer.tags" to="portainer.tags"
label="Tags" label="Tags"
isSubMenu
data-cy="portainerSidebar-environmentTags" data-cy="portainerSidebar-environmentTags"
/> />
{isBE && ( {isBE && (
<SidebarItem <SidebarItem
to="portainer.endpoints.updateSchedules" to="portainer.endpoints.updateSchedules"
label="Update & Rollback" label="Update & Rollback"
isSubMenu
data-cy="portainerSidebar-updateSchedules" data-cy="portainerSidebar-updateSchedules"
/> />
)} )}
</SidebarItem> </SidebarParent>
<SidebarItem <SidebarItem
label="Registries" label="Registries"
@ -95,18 +122,28 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
/> />
)} )}
<SidebarItem <SidebarParent
label="Authentication logs" label="Logs"
to="portainer.authLogs" to="portainer.authLogs"
icon={FileText} icon={FileText}
data-cy="portainerSidebar-authLogs" pathOptions={{
includePaths: ['portainer.activityLogs'],
}}
data-cy="k8sSidebar-logs"
> >
<SidebarItem
label="Authentication"
to="portainer.authLogs"
isSubMenu
data-cy="portainerSidebar-authLogs"
/>
<SidebarItem <SidebarItem
to="portainer.activityLogs" to="portainer.activityLogs"
label="Activity Logs" label="Activity"
isSubMenu
data-cy="portainerSidebar-activityLogs" data-cy="portainerSidebar-activityLogs"
/> />
</SidebarItem> </SidebarParent>
</> </>
)} )}
<SidebarItem <SidebarItem
@ -116,29 +153,42 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
data-cy="portainerSidebar-notifications" data-cy="portainerSidebar-notifications"
/> />
{isAdmin && ( {isAdmin && (
<SidebarItem <SidebarParent
to="portainer.settings" to="portainer.settings"
label="Settings" label="Settings"
icon={Settings} icon={Settings}
data-cy="portainerSidebar-settings" data-cy="portainerSidebar-settings"
> >
<SidebarItem
to="portainer.settings"
label="General"
isSubMenu
ignorePaths={[
'portainer.settings.authentication',
'portainer.settings.edgeCompute',
]}
data-cy="portainerSidebar-generalSettings"
/>
{!window.ddExtension && ( {!window.ddExtension && (
<SidebarItem <SidebarItem
to="portainer.settings.authentication" to="portainer.settings.authentication"
label="Authentication" label="Authentication"
isSubMenu
data-cy="portainerSidebar-authentication" data-cy="portainerSidebar-authentication"
/> />
)} )}
{isBE && ( {isBE && (
<SidebarItem <SidebarItem
to="portainer.settings.cloud" to="portainer.settings.sharedcredentials"
label="Cloud" label="Shared Credentials"
isSubMenu
data-cy="portainerSidebar-cloud" data-cy="portainerSidebar-cloud"
/> />
)} )}
<SidebarItem <SidebarItem
to="portainer.settings.edgeCompute" to="portainer.settings.edgeCompute"
label="Edge Compute" label="Edge Compute"
isSubMenu
data-cy="portainerSidebar-edgeCompute" data-cy="portainerSidebar-edgeCompute"
/> />
@ -151,12 +201,12 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
} }
target="_blank" target="_blank"
rel="noreferrer" 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 Help / About
</a> </a>
</SidebarItem.Wrapper> </SidebarItem.Wrapper>
</SidebarItem> </SidebarParent>
)} )}
</SidebarSection> </SidebarSection>
); );

@ -16,7 +16,8 @@
:global(#page-wrapper) { :global(#page-wrapper) {
--sidebar-width: 300px; --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 { :global(#page-wrapper.open) .root {
@ -45,3 +46,19 @@
margin: 0; margin: 0;
list-style: none; 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;
}

@ -12,12 +12,22 @@ import { SettingsSidebar } from './SettingsSidebar';
import { SidebarItem } from './SidebarItem'; import { SidebarItem } from './SidebarItem';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { Header } from './Header'; import { Header } from './Header';
import { SidebarProvider } from './useSidebarState'; import { SidebarProvider, useSidebarState } from './useSidebarState';
import { UpgradeBEBannerWrapper } from './UpgradeBEBanner'; import { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
export function Sidebar() { export function Sidebar() {
return (
/* in the future (when we remove r2a) this should wrap the whole app - to change root styles */
<SidebarProvider>
<InnerSidebar />
</SidebarProvider>
);
}
function InnerSidebar() {
const { isAdmin, user } = useUser(); const { isAdmin, user } = useUser();
const isTeamLeader = useIsTeamLeader(user) as boolean; const isTeamLeader = useIsTeamLeader(user) as boolean;
const { isOpen } = useSidebarState();
const settingsQuery = usePublicSettings(); const settingsQuery = usePublicSettings();
@ -28,37 +38,41 @@ export function Sidebar() {
const { LogoURL } = settingsQuery.data; const { LogoURL } = settingsQuery.data;
return ( return (
/* in the future (when we remove r2a) this should wrap the whole app - to change root styles */ <div className={clsx(styles.root, 'sidebar flex flex-col')}>
<SidebarProvider> <UpgradeBEBannerWrapper />
<div className={clsx(styles.root, 'sidebar flex flex-col')}> <nav
<UpgradeBEBannerWrapper /> className={clsx(
<nav styles.nav,
'flex flex-1 flex-col overflow-y-auto py-5 pl-5',
{ 'pr-5': isOpen }
)}
aria-label="Main"
>
<Header logo={LogoURL} />
{/* negative margin + padding -> scrollbar won't hide the content */}
<div
className={clsx( className={clsx(
styles.nav, styles.navListContainer,
'flex flex-1 flex-col overflow-y-auto p-5' 'mt-6 flex-1 overflow-y-auto [color-scheme:light] be:[color-scheme:dark] th-dark:[color-scheme:dark] th-highcontrast:[color-scheme:dark]',
{ 'pr-5 -mr-5': isOpen }
)} )}
aria-label="Main"
> >
<Header logo={LogoURL} /> <ul className={clsx('space-y-5', { 'w-[32px]': !isOpen })}>
{/* negative margin + padding -> scrollbar won't hide the content */} <SidebarItem
<div className="-mr-4 mt-6 flex-1 overflow-y-auto pr-4"> to="portainer.home"
<ul className="space-y-9"> icon={Home}
<SidebarItem label="Home"
to="portainer.home" data-cy="portainerSidebar-home"
icon={Home} />
label="Home" <EnvironmentSidebar />
data-cy="portainerSidebar-home" {isAdmin && <EdgeComputeSidebar />}
/> <SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
<EnvironmentSidebar /> </ul>
{isAdmin && <EdgeComputeSidebar />} </div>
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} /> <div className="mt-auto pt-8">
</ul> <Footer />
</div> </div>
<div className="mt-auto pt-8"> </nav>
<Footer /> </div>
</div>
</nav>
</div>
</SidebarProvider>
); );
} }

@ -1,52 +1,71 @@
import { ReactNode } from 'react'; import { Icon as IconTest } from 'lucide-react';
import { Icon } from 'lucide-react'; import clsx from 'clsx';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
import { useSidebarState } from '../useSidebarState';
import { Wrapper } from './Wrapper'; import { Wrapper } from './Wrapper';
import { Menu } from './Menu'; import { SidebarTooltip } from './SidebarTooltip';
import { Head } from './Head'; import { useSidebarSrefActive } from './useSidebarSrefActive';
import { getPathsForChildren } from './utils';
interface Props extends AutomationTestingProps { interface Props extends AutomationTestingProps {
icon?: Icon; icon?: IconTest;
to: string; to: string;
params?: object; params?: object;
label: string; label: string;
children?: ReactNode; isSubMenu?: boolean;
openOnPaths?: string[]; ignorePaths?: string[];
includePaths?: string[];
} }
export function SidebarItem({ export function SidebarItem({
children,
icon, icon,
to, to,
params, params,
label, label,
openOnPaths = [], isSubMenu = false,
ignorePaths = [],
includePaths = [],
'data-cy': dataCy, 'data-cy': dataCy,
}: Props) { }: Props) {
const childrenPath = getPathsForChildren(children); const { isOpen } = useSidebarState();
const head = ( const anchorProps = useSidebarSrefActive(to, undefined, params, undefined, {
<Head ignorePaths,
icon={icon} includePaths,
to={to} });
params={params}
label={label} const anchor = (
ignorePaths={childrenPath} <Wrapper label={label}>
data-cy={dataCy} <a
/> href={anchorProps.href}
onClick={anchorProps.onClick}
className={clsx(
anchorProps.className,
'text-inherit no-underline hover:text-inherit hover:no-underline focus:text-inherit focus:no-underline',
'flex h-8 w-full flex-1 items-center space-x-4 rounded-md text-sm',
'transition-colors duration-200 hover:bg-blue-5/20 be:hover:bg-gray-5/20 th-dark:hover:bg-gray-true-5/20',
{
// submenu items are always expanded (in a tooltip or in the sidebar)
'w-full justify-start px-3': isOpen || isSubMenu,
'w-8 justify-center': !isOpen && !isSubMenu,
}
)}
data-cy={dataCy}
>
{!!icon && <Icon icon={icon} className={clsx('flex [&>svg]:w-4')} />}
{(isOpen || isSubMenu) && <span>{label}</span>}
</a>
</Wrapper>
); );
if (isOpen || isSubMenu) return anchor;
return ( return (
<Wrapper label={label} className="sidebar"> <SidebarTooltip content={<span className="text-sm">{label}</span>}>
{children ? ( <span className="w-full">{anchor}</span>
<Menu head={head} openOnPaths={[...openOnPaths, ...childrenPath]}> </SidebarTooltip>
{children}
</Menu>
) : (
head
)}
</Wrapper>
); );
} }

@ -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<Props>) {
const anchorProps = useSidebarSrefActive(
to,
undefined,
params,
{},
pathOptions
);
const hasActiveChild = !!anchorProps.className;
const { isOpen: isSidebarOpen } = useSidebarState();
const [isExpanded, setIsExpanded] = useState(hasActiveChild);
const parentItem = (
<Wrapper className="flex flex-col">
<div
className={clsx(
'w-full h-8 items-center ease-in-out transition-colors flex duration-200 hover:bg-blue-5/20 be:hover:bg-gray-5/20 th-dark:hover:bg-gray-true-5/20 rounded-md',
isSidebarOpen && 'pl-3',
// only highlight the parent when the sidebar is closed/contracted and a child item is selected
(!isSidebarOpen || !isExpanded) && anchorProps.className
)}
data-cy={dataCy}
>
<button
type="button"
className="flex-1 h-full cursor-pointer flex items-center border-none bg-transparent"
onClick={() => setIsExpanded(true)}
>
<Link
to={to}
params={params}
className={clsx(
'w-full h-full font-medium items-center flex list-none border-none text-gray-5 hover:text-gray-5 hover:no-underline focus:text-gray-5 focus:no-underline',
{
'justify-start': isSidebarOpen,
'justify-center': !isSidebarOpen,
}
)}
>
<Icon icon={icon} />
{isSidebarOpen && <span className="ml-4">{title}</span>}
</Link>
</button>
{isSidebarOpen && (
<button
type="button"
className="flex-none border-none bg-transparent flex items-center group p-0 px-3 h-8"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center group-hover:bg-blue-5 be:group-hover:bg-gray-5 group-hover:th-dark:bg-gray-true-7 group-hover:bg-opacity-10 be:group-hover:bg-opacity-10 rounded-full p-[3px] transition ease-in-out">
<Icon
icon={ChevronDown}
size="md"
className={clsx('transition ease-in-out', {
'rotate-180': isExpanded,
'rotate-0': !isExpanded,
})}
/>
</div>
</button>
)}
</div>
</Wrapper>
);
const childList = (
<ul
// pl-11 must be important because it needs to avoid the padding from '.root ul' in sidebar.module.css
className={clsx('text-white !pl-11', {
hidden: !isExpanded,
block: isExpanded,
})}
>
{children}
</ul>
);
if (isSidebarOpen)
return (
<>
{parentItem}
{childList}
</>
);
return (
<SidebarTooltip
content={
<ul>
<li className="flex items-center space-x-2 text-sm mb-1">
<span>{title}</span>
</li>
<div className="bg-blue-8/50 be:bg-gray-8 th-dark:bg-gray-true-8 rounded">
{children}
</div>
</ul>
}
>
<span>{parentItem}</span>
</SidebarTooltip>
);
}

@ -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 (
<Tippy
className="sidebar !rounded-md bg-blue-9 p-3 !opacity-100 be:bg-gray-9 th-dark:bg-gray-true-9"
content={content}
delay={[0, 0]}
duration={[0, 0]}
zIndex={1000}
placement="right"
arrow
allowHTML
interactive
>
{children}
</Tippy>
);
}

@ -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<Record<string, string>> = {},
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;
}

@ -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[] { export function getPaths(element: ReactNode, paths: string[]): string[] {
if (!isReactElement(element)) { if (!isReactElement(element)) {
return paths; return paths;

Loading…
Cancel
Save