From aec65510822540e46c1542717ca6acf6424e090a Mon Sep 17 00:00:00 2001 From: Yassine Bounekhla Date: Fri, 13 Sep 2024 07:24:23 -0400 Subject: [PATCH 1/4] implement sidenav v1 --- web/packages/teleport/src/Main/Main.tsx | 51 +- .../teleport/src/Main/MainContainer.tsx | 2 + web/packages/teleport/src/Main/index.ts | 1 - .../SideNavigation/CategoryIcon.tsx | 40 ++ .../Navigation/SideNavigation/Navigation.tsx | 460 ++++++++++++++++++ .../Navigation/SideNavigation/categories.ts | 34 ++ web/packages/teleport/src/Navigation/index.ts | 1 + .../teleport/src/TopBar/TopBarSideNav.tsx | 167 +++++++ web/packages/teleport/src/features.tsx | 58 ++- .../services/storageService/storageService.ts | 4 + .../src/services/storageService/types.ts | 3 + web/packages/teleport/src/types.ts | 4 + 12 files changed, 770 insertions(+), 55 deletions(-) create mode 100644 web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx create mode 100644 web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx create mode 100644 web/packages/teleport/src/Navigation/SideNavigation/categories.ts create mode 100644 web/packages/teleport/src/TopBar/TopBarSideNav.tsx diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx index 2ed373312d8d..09bb2d5abd1d 100644 --- a/web/packages/teleport/src/Main/Main.tsx +++ b/web/packages/teleport/src/Main/Main.tsx @@ -27,7 +27,7 @@ import React, { useState, } from 'react'; import styled from 'styled-components'; -import { Box, Indicator } from 'design'; +import { Box, Flex, Indicator } from 'design'; import { Failed } from 'design/CardError'; import useAttempt from 'shared/hooks/useAttemptNext'; @@ -35,13 +35,13 @@ import useAttempt from 'shared/hooks/useAttemptNext'; import { matchPath, useHistory } from 'react-router'; import Dialog from 'design/Dialog'; -import { sharedStyles } from 'design/theme/themes/sharedStyles'; import { Redirect, Route, Switch } from 'teleport/components/Router'; import { CatchError } from 'teleport/components/CatchError'; import cfg from 'teleport/config'; import useTeleport from 'teleport/useTeleport'; import { TopBar } from 'teleport/TopBar'; +import { TopBar as TopBarSideNav } from 'teleport/TopBar/TopBarSideNav'; import { BannerList } from 'teleport/components/BannerList'; import { storageService } from 'teleport/services/storageService'; import { @@ -51,11 +51,9 @@ import { } from 'teleport/services/alerts/alerts'; import { useAlerts } from 'teleport/components/BannerList/useAlerts'; import { FeaturesContextProvider, useFeatures } from 'teleport/FeaturesContext'; -import { - getFirstRouteForCategory, - Navigation, -} from 'teleport/Navigation/Navigation'; -import { NavigationCategory } from 'teleport/Navigation/categories'; + +import { Navigation as SideNavigation } from 'teleport/Navigation/SideNavigation/Navigation'; +import { Navigation } from 'teleport/Navigation'; import { TopBarProps } from 'teleport/TopBar/TopBar'; import { useUser } from 'teleport/User/UserContext'; import { QuestionnaireProps } from 'teleport/Welcome/NewCredentials'; @@ -84,6 +82,10 @@ export function Main(props: MainProps) { const { preferences } = useUser(); + const useSideNav = storageService.getUseSideNav(); + const TopBarComponent = useSideNav ? TopBarSideNav : TopBar; + const NavigationComponent = useSideNav ? SideNavigation : Navigation; + useEffect(() => { if (ctx.storeUser.state) { setAttempt({ status: 'success' }); @@ -99,14 +101,6 @@ export function Main(props: MainProps) { () => props.features.filter(feature => feature.hasAccess(featureFlags)), [featureFlags, props.features] ); - const feature = features - .filter(feature => Boolean(feature.route)) - .find(f => - matchPath(history.location.pathname, { - path: f.route.path, - exact: f.route.exact ?? false, - }) - ); const { alerts, dismissAlert } = useAlerts(props.initialAlerts); @@ -168,7 +162,7 @@ export function Main(props: MainProps) { const indexRoute = cfg.isDashboard ? cfg.routes.downloadCenter - : getFirstRouteForCategory(features, NavigationCategory.Resources); + : cfg.getUnifiedResourcesRoute(cfg.proxyCluster); return ; } @@ -197,13 +191,10 @@ export function Main(props: MainProps) { const requiresOnboarding = onboard && !onboard.hasResource && !onboard.notified; const displayOnboardDiscover = requiresOnboarding && showOnboardDiscover; - const hasSidebar = - feature?.category === NavigationCategory.Management && - !feature?.hideNavigation; return ( - - - + + - + {displayOnboardDiscover && ( @@ -351,23 +342,15 @@ export const ContentMinWidth = ({ children }: { children: ReactNode }) => { ); }; -function getWidth(hasSidebar?: boolean) { - const { sidebarWidth } = sharedStyles; - if (hasSidebar) { - return `max-width: calc(100% - ${sidebarWidth}px);`; - } - return 'max-width: 100%;'; -} - -export const HorizontalSplit = styled.div<{ hasSidebar?: boolean }>` +export const ContentWrapper = styled.div` display: flex; flex-direction: column; flex: 1; - ${props => getWidth(props.hasSidebar)} overflow-x: auto; + max-width: 100%; `; -export const StyledIndicator = styled(HorizontalSplit)` +export const StyledIndicator = styled(Flex)` align-items: center; justify-content: center; position: absolute; diff --git a/web/packages/teleport/src/Main/MainContainer.tsx b/web/packages/teleport/src/Main/MainContainer.tsx index 6d3997601601..05aa772d37e1 100644 --- a/web/packages/teleport/src/Main/MainContainer.tsx +++ b/web/packages/teleport/src/Main/MainContainer.tsx @@ -28,6 +28,8 @@ export const MainContainer = styled.div` flex: 1; min-height: 0; --sidebar-width: 256px; + --sidenav-width: 76px; + --sidenav-panel-width: 224px; margin-top: ${p => p.theme.topBarHeight[0]}px; @media screen and (min-width: ${p => p.theme.breakpoints.small}px) { margin-top: ${p => p.theme.topBarHeight[1]}px; diff --git a/web/packages/teleport/src/Main/index.ts b/web/packages/teleport/src/Main/index.ts index 52815afd5f5f..1cd5bd2403c8 100644 --- a/web/packages/teleport/src/Main/index.ts +++ b/web/packages/teleport/src/Main/index.ts @@ -20,7 +20,6 @@ export { Main, useContentMinWidthContext, useNoMinWidth, - HorizontalSplit, StyledIndicator, } from './Main'; export { MainContainer } from './MainContainer'; diff --git a/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx b/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx new file mode 100644 index 000000000000..dd33214f7426 --- /dev/null +++ b/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx @@ -0,0 +1,40 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; + +import * as Icons from 'design/Icon'; + +import { NavigationCategory } from './categories'; + +export function CategoryIcon({ category }: { category: NavigationCategory }) { + switch (category) { + case NavigationCategory.Resources: + return ; + case NavigationCategory.Access: + return ; + case NavigationCategory.Identity: + return ; + case NavigationCategory.Policy: + return ; + case NavigationCategory.Audit: + return ; + default: + return null; + } +} diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx new file mode 100644 index 000000000000..5c7e46141e02 --- /dev/null +++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx @@ -0,0 +1,460 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState, useRef } from 'react'; +import styled, { css } from 'styled-components'; +import { matchPath, useHistory } from 'react-router'; +import { NavLink } from 'react-router-dom'; +import { Text, Flex, Box } from 'design'; +import { Theme } from 'design/theme/themes/types'; + +import cfg from 'teleport/config'; + +import { useFeatures } from 'teleport/FeaturesContext'; + +import { NavigationCategory, NAVIGATION_CATEGORIES } from './categories'; +import { CategoryIcon } from './CategoryIcon'; + +import type * as history from 'history'; + +import type { TeleportFeature } from 'teleport/types'; + +const zIndexMap = { + sideNavContainer: 21, + sideNavExpandedPanel: 20, +}; + +const SideNavContainer = styled(Flex).attrs({ + gap: 2, + pt: 2, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'start', + bg: 'levels.surface', +})` + height: 100%; + width: var(--sidenav-width); + position: relative; + border-right: 1px solid ${p => p.theme.colors.spotBackground[1]}; + z-index: ${zIndexMap.sideNavContainer}; + overflow-y: hidden; +`; + +const verticalPadding = '12px'; + +const RightPanel = styled(Box).attrs({ pt: 2, pb: 4, px: 2 })<{ + isVisible: boolean; +}>` + position: absolute; + top: 0; + left: var(--sidenav-width); + height: 100%; + overflow: clip; + width: 224px; + background: ${p => p.theme.colors.levels.surface}; + z-index: ${zIndexMap.sideNavExpandedPanel}; + border-right: 1px solid ${p => p.theme.colors.spotBackground[1]}; + + transition: transform 0.3s ease-in-out; + ${props => + props.isVisible + ? ` + transition: transform .15s ease-out; + transform: translateX(0); + ` + : ` + transition: transform .15s ease-in; + transform: translateX(-100%); + `} + + padding-top: ${p => p.theme.topBarHeight[0]}px; + @media screen and (min-width: ${p => p.theme.breakpoints.small}) { + padding-top: ${p => p.theme.topBarHeight[1]}px; + } + @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { + padding-top: ${p => p.theme.topBarHeight[2]}px; + } +`; + +type NavigationSection = { + category: NavigationCategory; + subsections: NavigationSubsection[]; +}; + +type NavigationSubsection = { + category: NavigationCategory; + title: string; + route: string; + exact: boolean; + icon: (props) => JSX.Element; + parent?: TeleportFeature; +}; + +function getNavigationSections( + features: TeleportFeature[] +): NavigationSection[] { + const navigationSections = NAVIGATION_CATEGORIES.map(category => ({ + category, + subsections: getSubsectionsForCategory(category, features), + })); + + return navigationSections; +} + +function getSubsectionsForCategory( + category: NavigationCategory, + features: TeleportFeature[] +): NavigationSubsection[] { + const filteredFeatures = features.filter( + feature => + feature.sideNavCategory === category && + !!feature.navigationItem && + !feature.parent + ); + + return filteredFeatures.map(feature => { + return { + category, + title: feature.navigationItem.title, + route: feature.navigationItem.getLink(cfg.proxyCluster), + exact: feature.navigationItem.exact, + icon: feature.navigationItem.icon, + }; + }); +} + +function getNavSubsectionForRoute( + features: TeleportFeature[], + route: history.Location | Location +): NavigationSubsection { + const feature = features + .filter(feature => Boolean(feature.route)) + .find(feature => + matchPath(route.pathname, { + path: feature.route.path, + exact: false, + }) + ); + + if (!feature) { + return; + } + + return { + category: feature.sideNavCategory, + title: feature.navigationItem.title, + route: feature.navigationItem.getLink(cfg.proxyCluster), + exact: feature.navigationItem.exact, + icon: feature.navigationItem.icon, + }; +} + +export function Navigation() { + const features = useFeatures(); + const history = useHistory(); + const firstSubsectionItemRef = useRef(); + const [expandedSection, setExpandedSection] = useState(); + + const currentView = getNavSubsectionForRoute(features, history.location); + + // TODO(rudream): Implement cloud dashboard view. + const navSections = getNavigationSections(features).filter( + section => section.subsections.length + ); + + return ( + setExpandedSection(null)} + onKeyUp={e => e.key === 'Escape' && setExpandedSection(null)} + > + + {navSections.map(section => ( +
+ ))} + + {/* // TODO(rudream): Implement button to make panel sticky.*/} + + + + {expandedSection?.category} + + + {expandedSection?.subsections.map((section, idx) => ( + ) + : null + } + active={currentView.route === section.route} + to={section.route} + exact={section.exact} + key={section.title} + tabIndex={0} + role="button" + > + + {section.title} + + ))} + + {/* TODO(rudream): Figure out best place for for license footers. + + {cfg.edition === 'oss' && } + {cfg.edition === 'community' && } + */} + + ); +} + +const SubsectionItem = styled(NavLink)<{ active: boolean }>` + display: flex; + position: relative; + color: ${props => props.theme.colors.text.slightlyMuted}; + text-decoration: none; + user-select: none; + gap: ${props => props.theme.space[2]}px; + padding-top: ${verticalPadding}; + padding-bottom: ${verticalPadding}; + padding-left: ${props => props.theme.space[3]}px; + padding-right: ${props => props.theme.space[3]}px; + border-radius: ${props => props.theme.radii[2]}px; + cursor: pointer; + + ${props => getSubsectionStyles(props.theme, props.active)} +`; + +function Section({ + section, + active, + setExpandedSection, + firstSubsectionItemRef, +}: { + section: NavigationSection; + active: boolean; + setExpandedSection: (category: NavigationSection) => void; + firstSubsectionItemRef: React.MutableRefObject; +}) { + return ( + setExpandedSection(section)} + onFocus={() => setExpandedSection(section)} + onKeyUp={e => e.key === 'Enter' && firstSubsectionItemRef.current.focus()} + > + + {section.category} + + ); +} + +const CategoryButton = styled.button<{ active: boolean }>` + height: 60px; + width: 60px; + cursor: pointer; + outline: hidden; + border: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: ${props => props.theme.radii[2]}px; + + font-size: ${props => props.theme.typography.body4.fontSize}; + font-weight: ${props => props.theme.typography.body4.fontWeight}; + letter-spacing: ${props => props.theme.typography.body4.letterSpacing}; + line-height: ${props => props.theme.typography.body4.lineHeight}; + + ${props => getCategoryStyles(props.theme, props.active)} +`; + +function getCategoryStyles(theme: Theme, active: boolean) { + if (active) { + return css` + color: ${theme.colors.brand}; + background: ${theme.colors.interactive.tonal.primary[0].background}; + &:hover, + &:focus { + background: ${theme.colors.interactive.tonal.primary[1].background}; + color: ${theme.colors.interactive.tonal.primary[0].text}; + } + &:active { + background: ${theme.colors.interactive.tonal.primary[2].background}; + color: ${theme.colors.interactive.tonal.primary[1].text}; + } + `; + } + + return css` + background: transparent; + color: ${props => props.theme.colors.text.slightlyMuted}; + &:hover, + &:focus { + background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; + color: ${props => props.theme.colors.text.main}; + } + &:active { + background: ${props => props.theme.colors.interactive.tonal.neutral[1]}; + color: ${props => props.theme.colors.text.main}; + } + `; +} + +function getSubsectionStyles(theme: Theme, active: boolean) { + if (active) { + return css` + color: ${theme.colors.brand}; + background: ${theme.colors.interactive.tonal.primary[0].background}; + &:focus { + border: 2px solid + ${theme.colors.interactive.solid.primary.default.background}; + } + &:hover { + background: ${theme.colors.interactive.tonal.primary[1].background}; + color: ${theme.colors.interactive.tonal.primary[0].text}; + } + &:active { + background: ${theme.colors.interactive.tonal.primary[2].background}; + color: ${theme.colors.interactive.tonal.primary[1].text}; + } + `; + } + + return css` + color: ${props => props.theme.colors.text.slightlyMuted}; + &:focus { + border: 2px solid ${theme.colors.text.muted}; + } + &:hover { + background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; + color: ${props => props.theme.colors.text.main}; + } + &:active { + background: ${props => props.theme.colors.interactive.tonal.neutral[1]}; + color: ${props => props.theme.colors.text.main}; + } + `; +} + +// TODO(rudream): Figure out best place for for license footers. +// function AGPLFooter() { +// return ( +// +// {/* This is an independently compiled AGPL-3.0 version of Teleport. You */} +// {/* can find the official release on{' '} */} +// This is an independently compiled AGPL-3.0 version of Teleport. +//
+// Visit{' '} +// +// the Downloads page +// {' '} +// for the official release. +// +// } +// /> +// ); +// } + +// function CommunityFooter() { +// return ( +// +// +// Upgrade to Teleport Enterprise +// {' '} +// for SSO, just-in-time access requests, Access Graph, and much more! +// +// } +// /> +// ); +// } + +// function LicenseFooter({ +// title, +// subText, +// infoContent, +// }: { +// title: string; +// subText: string; +// infoContent: JSX.Element; +// }) { +// const [opened, setOpened] = useState(false); +// return ( +// setOpened(false)}> +// +// {title} +// setOpened(true)}> +// +// {opened && {infoContent}} +// +// +// {subText} +// +// ); +// } + +// const StyledFooterBox = styled(Box)` +// line-height: 20px; +// border-top: ${props => props.theme.borders[1]} +// ${props => props.theme.colors.spotBackground[0]}; +// `; + +// const SubText = styled(Text)` +// color: ${props => props.theme.colors.text.disabled}; +// font-size: ${props => props.theme.fontSizes[1]}px; +// `; + +// const TooltipContent = styled(Box)` +// width: max-content; +// position: absolute; +// bottom: 0; +// left: 24px; +// padding: 12px 16px 12px 16px; +// box-shadow: ${p => p.theme.boxShadow[1]}; +// background-color: ${props => props.theme.colors.tooltip.background}; +// z-index: 20; +// `; + +// const FooterContent = styled(Flex)` +// position: relative; +// `; diff --git a/web/packages/teleport/src/Navigation/SideNavigation/categories.ts b/web/packages/teleport/src/Navigation/SideNavigation/categories.ts new file mode 100644 index 000000000000..fd34b94214fb --- /dev/null +++ b/web/packages/teleport/src/Navigation/SideNavigation/categories.ts @@ -0,0 +1,34 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export enum NavigationCategory { + Resources = 'Resources', + Access = 'Access', + Identity = 'Identity', + Policy = 'Policy', + Audit = 'Audit', + AddNew = 'Add New', +} + +export const NAVIGATION_CATEGORIES = [ + NavigationCategory.Resources, + NavigationCategory.Access, + NavigationCategory.Identity, + NavigationCategory.Policy, + NavigationCategory.Audit, +]; diff --git a/web/packages/teleport/src/Navigation/index.ts b/web/packages/teleport/src/Navigation/index.ts index 45911a88936a..320bdcf70c52 100644 --- a/web/packages/teleport/src/Navigation/index.ts +++ b/web/packages/teleport/src/Navigation/index.ts @@ -16,4 +16,5 @@ * along with this program. If not, see . */ +export { Navigation as SideNavigation } from './SideNavigation/Navigation'; export { Navigation } from './Navigation'; diff --git a/web/packages/teleport/src/TopBar/TopBarSideNav.tsx b/web/packages/teleport/src/TopBar/TopBarSideNav.tsx new file mode 100644 index 000000000000..2accd11de393 --- /dev/null +++ b/web/packages/teleport/src/TopBar/TopBarSideNav.tsx @@ -0,0 +1,167 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import styled, { useTheme } from 'styled-components'; +import { Link } from 'react-router-dom'; +import { Flex, Image, TopNav } from 'design'; +import { matchPath, useHistory } from 'react-router'; +import { Theme } from 'design/theme/themes/types'; +import { HoverTooltip } from 'shared/components/ToolTip'; + +import useTeleport from 'teleport/useTeleport'; +import { UserMenuNav } from 'teleport/components/UserMenuNav'; +import { useFeatures } from 'teleport/FeaturesContext'; +import cfg from 'teleport/config'; +import { useLayout } from 'teleport/Main/LayoutContext'; +import { logos } from 'teleport/components/LogoHero/LogoHero'; + +import { Notifications } from 'teleport/Notifications'; + +export function TopBar({ CustomLogo }: TopBarProps) { + const ctx = useTeleport(); + const history = useHistory(); + const features = useFeatures(); + const { currentWidth } = useLayout(); + const theme: Theme = useTheme(); + + // find active feature + const feature = features + .filter(feature => Boolean(feature.route)) + .find(f => + matchPath(history.location.pathname, { + path: f.route.path, + exact: f.route.exact ?? false, + }) + ); + + history?.location?.pathname === cfg.routes.downloadCenter; + const iconSize = + currentWidth >= theme.breakpoints.medium + ? navigationIconSizeMedium + : navigationIconSizeSmall; + + return ( + + {!feature?.hideNavigation && } + {!feature?.logoOnlyTopbar && ( + + + + + )} + + ); +} + +export const TopBarContainer = styled(TopNav)` + position: absolute; + width: 100%; + display: flex; + justify-content: space-between; + background: ${p => p.theme.colors.levels.surface}; + overflow-y: initial; + overflow-x: none; + flex-shrink: 0; + z-index: 23; + border-bottom: 1px solid ${({ theme }) => theme.colors.spotBackground[1]}; + + height: ${p => p.theme.topBarHeight[0]}px; + @media screen and (min-width: ${p => p.theme.breakpoints.small}px) { + height: ${p => p.theme.topBarHeight[1]}px; + } + @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { + height: ${p => p.theme.topBarHeight[2]}px; + } +`; + +const TeleportLogo = ({ CustomLogo }: TopBarProps) => { + const theme = useTheme(); + const src = logos[cfg.edition][theme.type]; + + return ( + p.theme.breakpoints.medium}px) { + margin-right: 76px; + } + @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { + margin-right: 67px; + } + `} + > + + p.theme.colors.interactive.tonal.primary[0].background}; + } + align-items: center; + `} + to={cfg.routes.root} + > + {CustomLogo ? ( + + ) : ( + teleport logo props.theme.space[3]}px; + padding-right: ${props => props.theme.space[3]}px; + height: 18px; + @media screen and (min-width: ${p => + p.theme.breakpoints.small}px) { + height: 28px; + padding-left: ${props => props.theme.space[4]}px; + padding-right: ${props => props.theme.space[4]}px; + } + @media screen and (min-width: ${p => + p.theme.breakpoints.large}px) { + height: 30px; + } + `} + /> + )} + + + ); +}; + +export const navigationIconSizeSmall = 20; +export const navigationIconSizeMedium = 24; + +export type NavigationItem = { + title: string; + path: string; + Icon: JSX.Element; +}; + +export type TopBarProps = { + CustomLogo?: () => React.ReactElement; + showPoweredByLogo?: boolean; +}; diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 61c6dffe7eee..318827c4699c 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -42,9 +42,10 @@ import { import cfg from 'teleport/config'; import { - ManagementSection, NavigationCategory, + ManagementSection, } from 'teleport/Navigation/categories'; +import { NavigationCategory as SideNavigationCategory } from 'teleport/Navigation/SideNavigation/categories'; import { IntegrationEnroll } from '@gravitational/teleport/src/Integrations/Enroll'; import { NavTitle } from './types'; @@ -74,6 +75,7 @@ import type { FeatureFlags, TeleportFeature } from './types'; class AccessRequests implements TeleportFeature { category = NavigationCategory.Resources; + sideNavCategory = SideNavigationCategory.Resources; route = { title: 'Access Requests', @@ -101,6 +103,7 @@ class AccessRequests implements TeleportFeature { export class FeatureJoinTokens implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.Access; navigationItem = { title: NavTitle.JoinTokens, icon: Key, @@ -123,6 +126,9 @@ export class FeatureJoinTokens implements TeleportFeature { } export class FeatureUnifiedResources implements TeleportFeature { + category = NavigationCategory.Resources; + sideNavCategory = SideNavigationCategory.Resources; + route = { title: 'Resources', path: cfg.routes.unifiedResources, @@ -139,8 +145,6 @@ export class FeatureUnifiedResources implements TeleportFeature { }, }; - category = NavigationCategory.Resources; - hasAccess() { return !cfg.isDashboard; } @@ -152,6 +156,7 @@ export class FeatureUnifiedResources implements TeleportFeature { export class FeatureSessions implements TeleportFeature { category = NavigationCategory.Resources; + sideNavCategory = SideNavigationCategory.Audit; route = { title: 'Active Sessions', @@ -184,6 +189,7 @@ export class FeatureSessions implements TeleportFeature { export class FeatureUsers implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.Access; route = { title: 'Manage Users', @@ -213,6 +219,7 @@ export class FeatureUsers implements TeleportFeature { export class FeatureBots implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.Access; route = { title: 'Manage Bots', @@ -242,6 +249,7 @@ export class FeatureBots implements TeleportFeature { export class FeatureAddBots implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.Access; hideFromNavigation = true; route = { @@ -263,6 +271,7 @@ export class FeatureAddBots implements TeleportFeature { export class FeatureRoles implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Permissions; + sideNavCategory = SideNavigationCategory.Access; route = { title: 'Manage User Roles', @@ -288,6 +297,7 @@ export class FeatureRoles implements TeleportFeature { export class FeatureAuthConnectors implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.Access; route = { title: 'Manage Auth Connectors', @@ -313,9 +323,10 @@ export class FeatureAuthConnectors implements TeleportFeature { export class FeatureLocks implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Identity; + sideNavCategory = SideNavigationCategory.Identity; route = { - title: 'Manage Session & Identity Locks', + title: 'Session & Identity Locks', path: cfg.routes.locks, exact: true, component: Locks, @@ -355,6 +366,10 @@ export class FeatureNewLock implements TeleportFeature { } export class FeatureDiscover implements TeleportFeature { + category = NavigationCategory.Management; + section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.AddNew; + route = { title: 'Enroll New Resource', path: cfg.routes.discover, @@ -371,9 +386,6 @@ export class FeatureDiscover implements TeleportFeature { }, }; - category = NavigationCategory.Management; - section = ManagementSection.Access; - hasAccess(flags: FeatureFlags) { return flags.discover; } @@ -384,6 +396,7 @@ export class FeatureDiscover implements TeleportFeature { } export class FeatureIntegrations implements TeleportFeature { + sideNavCategory = SideNavigationCategory.Access; category = NavigationCategory.Management; section = ManagementSection.Access; @@ -415,6 +428,8 @@ export class FeatureIntegrations implements TeleportFeature { export class FeatureIntegrationEnroll implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; + sideNavCategory = SideNavigationCategory.Access; + parent = FeatureIntegrations; route = { title: 'Enroll New Integration', @@ -447,6 +462,7 @@ export class FeatureIntegrationEnroll implements TeleportFeature { export class FeatureRecordings implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Activity; + sideNavCategory = SideNavigationCategory.Audit; route = { title: 'Session Recordings', @@ -472,6 +488,7 @@ export class FeatureRecordings implements TeleportFeature { export class FeatureAudit implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Activity; + sideNavCategory = SideNavigationCategory.Audit; route = { title: 'Audit Log', @@ -497,6 +514,7 @@ export class FeatureAudit implements TeleportFeature { export class FeatureClusters implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Clusters; + sideNavCategory = SideNavigationCategory.Access; route = { title: 'Clusters', @@ -522,6 +540,7 @@ export class FeatureClusters implements TeleportFeature { export class FeatureTrust implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Clusters; + sideNavCategory = SideNavigationCategory.Access; route = { title: 'Trusted Root Clusters', @@ -545,8 +564,9 @@ export class FeatureTrust implements TeleportFeature { class FeatureDeviceTrust implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Identity; + sideNavCategory = SideNavigationCategory.Identity; route = { - title: 'Manage Trusted Devices', + title: 'Trusted Devices', path: cfg.routes.deviceTrust, exact: true, component: DeviceTrustLocked, @@ -615,37 +635,35 @@ export class FeatureHelpAndSupport implements TeleportFeature { export function getOSSFeatures(): TeleportFeature[] { return [ // Resources + // TODO(rudream): Implement shortcuts to pinned/nodes/apps/dbs/desktops/kubes. new FeatureUnifiedResources(), - new AccessRequests(), - new FeatureSessions(), // Management // - Access new FeatureUsers(), + new FeatureRoles(), new FeatureBots(), new FeatureAddBots(), + new FeatureJoinTokens(), new FeatureAuthConnectors(), new FeatureIntegrations(), - new FeatureJoinTokens(), - new FeatureDiscover(), new FeatureIntegrationEnroll(), - - // - Permissions - new FeatureRoles(), + new FeatureClusters(), + new FeatureTrust(), // - Identity + new AccessRequests(), new FeatureLocks(), new FeatureNewLock(), new FeatureDeviceTrust(), - // - Activity - new FeatureRecordings(), + // - Audit new FeatureAudit(), + new FeatureRecordings(), + new FeatureSessions(), - // - Clusters - new FeatureClusters(), - new FeatureTrust(), + new FeatureDiscover(), // Other new FeatureAccount(), diff --git a/web/packages/teleport/src/services/storageService/storageService.ts b/web/packages/teleport/src/services/storageService/storageService.ts index 644543046f59..638bd93cba15 100644 --- a/web/packages/teleport/src/services/storageService/storageService.ts +++ b/web/packages/teleport/src/services/storageService/storageService.ts @@ -260,4 +260,8 @@ export const storageService = { JSON.stringify(true) ); }, + + getUseSideNav(): boolean { + return this.getParsedJSONValue(KeysEnum.USE_SIDENAV, false); + }, }; diff --git a/web/packages/teleport/src/services/storageService/types.ts b/web/packages/teleport/src/services/storageService/types.ts index 71ad6d5992ab..723562fa8bc2 100644 --- a/web/packages/teleport/src/services/storageService/types.ts +++ b/web/packages/teleport/src/services/storageService/types.ts @@ -36,6 +36,9 @@ export const KeysEnum = { USERS_NOT_EQUAL_TO_MAU_ACKNOWLEDGED: 'grv_users_not_equal_to_mau_acknowledged', LOCAL_NOTIFICATION_STATES: 'grv_teleport_notification_states', + + //TODO(rudream): Remove once sidenav implementation is complete. + USE_SIDENAV: 'grv_teleport_use_sidenav', }; // SurveyRequest is the request for sending data to the back end diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index d6f370bac769..7b91411cd963 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -25,6 +25,8 @@ import { NavigationCategory, } from 'teleport/Navigation/categories'; +import { NavigationCategory as SideNavigationCategory } from './Navigation/SideNavigation/categories'; + export type NavGroup = 'team' | 'activity' | 'clusters' | 'accessrequests'; export interface Context { @@ -106,6 +108,8 @@ export interface TeleportFeatureRoute { export interface TeleportFeature { parent?: new () => TeleportFeature | null; category?: NavigationCategory; + // TODO(rudream): Delete category field above and rename sideNavCategory field to category once old nav is removed. + sideNavCategory?: SideNavigationCategory; section?: ManagementSection; hasAccess(flags: FeatureFlags): boolean; // logoOnlyTopbar is used to optionally hide the elements in the topbar from view except for the logo. From a6c2417bf579b731b277ad13d2b3876ee7ff94e6 Mon Sep 17 00:00:00 2001 From: Yassine Bounekhla Date: Fri, 20 Sep 2024 04:01:04 -0400 Subject: [PATCH 2/4] CR --- web/packages/teleport/src/Main/Main.tsx | 8 +++---- .../teleport/src/Main/MainContainer.tsx | 1 - web/packages/teleport/src/Main/index.ts | 1 + web/packages/teleport/src/Main/zIndexMap.ts | 23 +++++++++++++++++++ .../Navigation/SideNavigation/Navigation.tsx | 22 +++++++++--------- .../teleport/src/TopBar/TopBarSideNav.tsx | 12 +++++----- .../services/storageService/storageService.ts | 4 ++-- .../src/services/storageService/types.ts | 2 +- 8 files changed, 48 insertions(+), 25 deletions(-) create mode 100644 web/packages/teleport/src/Main/zIndexMap.ts diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx index 09bb2d5abd1d..3aa34cda1753 100644 --- a/web/packages/teleport/src/Main/Main.tsx +++ b/web/packages/teleport/src/Main/Main.tsx @@ -82,9 +82,9 @@ export function Main(props: MainProps) { const { preferences } = useUser(); - const useSideNav = storageService.getUseSideNav(); - const TopBarComponent = useSideNav ? TopBarSideNav : TopBar; - const NavigationComponent = useSideNav ? SideNavigation : Navigation; + const isTopBarView = storageService.getIsTopBarView(); + const TopBarComponent = isTopBarView ? TopBar : TopBarSideNav; + const NavigationComponent = isTopBarView ? Navigation : SideNavigation; useEffect(() => { if (ctx.storeUser.state) { @@ -363,5 +363,5 @@ const Wrapper = styled(Box)` display: flex; height: 100vh; flex-direction: column; - width: 100vw; + max-width: 100vw; `; diff --git a/web/packages/teleport/src/Main/MainContainer.tsx b/web/packages/teleport/src/Main/MainContainer.tsx index 05aa772d37e1..4f7910b80e5d 100644 --- a/web/packages/teleport/src/Main/MainContainer.tsx +++ b/web/packages/teleport/src/Main/MainContainer.tsx @@ -26,7 +26,6 @@ import styled from 'styled-components'; export const MainContainer = styled.div` display: flex; flex: 1; - min-height: 0; --sidebar-width: 256px; --sidenav-width: 76px; --sidenav-panel-width: 224px; diff --git a/web/packages/teleport/src/Main/index.ts b/web/packages/teleport/src/Main/index.ts index 1cd5bd2403c8..ca454d1ce48a 100644 --- a/web/packages/teleport/src/Main/index.ts +++ b/web/packages/teleport/src/Main/index.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +export { zIndexMap } from './zIndexMap'; export { Main, useContentMinWidthContext, diff --git a/web/packages/teleport/src/Main/zIndexMap.ts b/web/packages/teleport/src/Main/zIndexMap.ts new file mode 100644 index 000000000000..75d1ecdc2f82 --- /dev/null +++ b/web/packages/teleport/src/Main/zIndexMap.ts @@ -0,0 +1,23 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export const zIndexMap = { + topBar: 22, + sideNavContainer: 21, + sideNavExpandedPanel: 20, +}; diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx index 5c7e46141e02..dccb981a7476 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx @@ -26,6 +26,7 @@ import { Theme } from 'design/theme/themes/types'; import cfg from 'teleport/config'; import { useFeatures } from 'teleport/FeaturesContext'; +import { zIndexMap } from 'teleport/Main'; import { NavigationCategory, NAVIGATION_CATEGORIES } from './categories'; import { CategoryIcon } from './CategoryIcon'; @@ -34,11 +35,6 @@ import type * as history from 'history'; import type { TeleportFeature } from 'teleport/types'; -const zIndexMap = { - sideNavContainer: 21, - sideNavExpandedPanel: 20, -}; - const SideNavContainer = styled(Flex).attrs({ gap: 2, pt: 2, @@ -52,11 +48,13 @@ const SideNavContainer = styled(Flex).attrs({ position: relative; border-right: 1px solid ${p => p.theme.colors.spotBackground[1]}; z-index: ${zIndexMap.sideNavContainer}; - overflow-y: hidden; + overflow-y: auto; `; const verticalPadding = '12px'; +const rightPanelWidth = '224px'; + const RightPanel = styled(Box).attrs({ pt: 2, pb: 4, px: 2 })<{ isVisible: boolean; }>` @@ -64,8 +62,9 @@ const RightPanel = styled(Box).attrs({ pt: 2, pb: 4, px: 2 })<{ top: 0; left: var(--sidenav-width); height: 100%; - overflow: clip; - width: 224px; + scrollbar-gutter: auto; + overflow-y: auto; + width: ${rightPanelWidth}; background: ${p => p.theme.colors.levels.surface}; z-index: ${zIndexMap.sideNavExpandedPanel}; border-right: 1px solid ${p => p.theme.colors.spotBackground[1]}; @@ -83,7 +82,7 @@ const RightPanel = styled(Box).attrs({ pt: 2, pb: 4, px: 2 })<{ `} padding-top: ${p => p.theme.topBarHeight[0]}px; - @media screen and (min-width: ${p => p.theme.breakpoints.small}) { + @media screen and (min-width: ${p => p.theme.breakpoints.small}px) { padding-top: ${p => p.theme.topBarHeight[1]}px; } @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { @@ -181,6 +180,7 @@ export function Navigation() { setExpandedSection(null)} onKeyUp={e => e.key === 'Escape' && setExpandedSection(null)} + css={'height: 100%;'} > {navSections.map(section => ( @@ -331,7 +331,7 @@ function getSubsectionStyles(theme: Theme, active: boolean) { color: ${theme.colors.brand}; background: ${theme.colors.interactive.tonal.primary[0].background}; &:focus { - border: 2px solid + outline: 2px solid ${theme.colors.interactive.solid.primary.default.background}; } &:hover { @@ -348,7 +348,7 @@ function getSubsectionStyles(theme: Theme, active: boolean) { return css` color: ${props => props.theme.colors.text.slightlyMuted}; &:focus { - border: 2px solid ${theme.colors.text.muted}; + outline: 2px solid ${theme.colors.text.muted}; } &:hover { background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; diff --git a/web/packages/teleport/src/TopBar/TopBarSideNav.tsx b/web/packages/teleport/src/TopBar/TopBarSideNav.tsx index 2accd11de393..5860bcdc5ca8 100644 --- a/web/packages/teleport/src/TopBar/TopBarSideNav.tsx +++ b/web/packages/teleport/src/TopBar/TopBarSideNav.tsx @@ -32,6 +32,7 @@ import { useLayout } from 'teleport/Main/LayoutContext'; import { logos } from 'teleport/components/LogoHero/LogoHero'; import { Notifications } from 'teleport/Notifications'; +import { zIndexMap } from 'teleport/Main'; export function TopBar({ CustomLogo }: TopBarProps) { const ctx = useTeleport(); @@ -41,16 +42,15 @@ export function TopBar({ CustomLogo }: TopBarProps) { const theme: Theme = useTheme(); // find active feature - const feature = features - .filter(feature => Boolean(feature.route)) - .find(f => + const feature = features.find( + f => + f.route && matchPath(history.location.pathname, { path: f.route.path, exact: f.route.exact ?? false, }) - ); + ); - history?.location?.pathname === cfg.routes.downloadCenter; const iconSize = currentWidth >= theme.breakpoints.medium ? navigationIconSizeMedium @@ -78,7 +78,7 @@ export const TopBarContainer = styled(TopNav)` overflow-y: initial; overflow-x: none; flex-shrink: 0; - z-index: 23; + z-index: ${zIndexMap.topBar}; border-bottom: 1px solid ${({ theme }) => theme.colors.spotBackground[1]}; height: ${p => p.theme.topBarHeight[0]}px; diff --git a/web/packages/teleport/src/services/storageService/storageService.ts b/web/packages/teleport/src/services/storageService/storageService.ts index 638bd93cba15..f272b8943fb3 100644 --- a/web/packages/teleport/src/services/storageService/storageService.ts +++ b/web/packages/teleport/src/services/storageService/storageService.ts @@ -261,7 +261,7 @@ export const storageService = { ); }, - getUseSideNav(): boolean { - return this.getParsedJSONValue(KeysEnum.USE_SIDENAV, false); + getIsTopBarView(): boolean { + return this.getParsedJSONValue(KeysEnum.USE_TOP_BAR, false); }, }; diff --git a/web/packages/teleport/src/services/storageService/types.ts b/web/packages/teleport/src/services/storageService/types.ts index 723562fa8bc2..9eda32f0051f 100644 --- a/web/packages/teleport/src/services/storageService/types.ts +++ b/web/packages/teleport/src/services/storageService/types.ts @@ -38,7 +38,7 @@ export const KeysEnum = { LOCAL_NOTIFICATION_STATES: 'grv_teleport_notification_states', //TODO(rudream): Remove once sidenav implementation is complete. - USE_SIDENAV: 'grv_teleport_use_sidenav', + USE_TOP_BAR: 'grv_teleport_use_topbar', }; // SurveyRequest is the request for sending data to the back end From 1164bcfe39861e74bf38440641962b5ed4b686c1 Mon Sep 17 00:00:00 2001 From: Yassine Bounekhla Date: Fri, 20 Sep 2024 06:44:45 -0400 Subject: [PATCH 3/4] improve keyboard nav --- .../Navigation/SideNavigation/Navigation.tsx | 130 +++++++++++++----- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx index dccb981a7476..b623ef0a4cda 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import styled, { css } from 'styled-components'; import { matchPath, useHistory } from 'react-router'; import { NavLink } from 'react-router-dom'; @@ -150,7 +150,8 @@ function getNavSubsectionForRoute( }) ); - if (!feature) { + if (!feature || !feature.sideNavCategory) { + console.log('TRUE'); return; } @@ -166,16 +167,65 @@ function getNavSubsectionForRoute( export function Navigation() { const features = useFeatures(); const history = useHistory(); - const firstSubsectionItemRef = useRef(); - const [expandedSection, setExpandedSection] = useState(); + const [expandedSection, setExpandedSection] = + useState(null); + const [expandedSectionIndex, setExpandedSectionIndex] = useState(-1); const currentView = getNavSubsectionForRoute(features, history.location); - // TODO(rudream): Implement cloud dashboard view. const navSections = getNavigationSections(features).filter( section => section.subsections.length ); + const sectionRefs = useRef>([]); + const subsectionRefs = useRef>([]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, index: number, isSubsection: boolean) => { + if (event.key === 'Tab') { + if (!isSubsection) return; + + // When the user presses shift+tab on first subsection item of a section. + if (event.shiftKey && index === 0) { + event.preventDefault(); + const prevSectionIndex = + (expandedSectionIndex - 1 + navSections.length) % + navSections.length; + sectionRefs.current[prevSectionIndex]?.focus(); + } else if ( + !event.shiftKey && + index === expandedSection.subsections.length - 1 + ) { + // When the user presses tab on last subsection item of a section. + event.preventDefault(); + const nextSectionIndex = + (expandedSectionIndex + 1) % navSections.length; + sectionRefs.current[nextSectionIndex]?.focus(); + } + } else if (event.key === 'Enter' && !isSubsection) { + event.preventDefault(); + subsectionRefs.current[0]?.focus(); + } + }, + [expandedSection, expandedSectionIndex, navSections.length] + ); + + const handleSetExpandedSection = useCallback( + (section: NavigationSection, index: number) => { + setExpandedSection(section); + setExpandedSectionIndex(index); + }, + [] + ); + + // Reset subsectionRefs when expanded section changes + useEffect(() => { + subsectionRefs.current = subsectionRefs.current.slice( + 0, + expandedSection?.subsections.length || 0 + ); + }, [expandedSection]); + return ( setExpandedSection(null)} @@ -183,18 +233,18 @@ export function Navigation() { css={'height: 100%;'} > - {navSections.map(section => ( + {navSections.map((section, index) => (
handleSetExpandedSection(section, index)} aria-controls={`panel-${expandedSection?.category}`} - firstSubsectionItemRef={firstSubsectionItemRef} + ref={el => (sectionRefs.current[index] = el)} + onKeyDown={e => handleKeyDown(e, index, false)} /> ))} - {/* // TODO(rudream): Implement button to make panel sticky.*/} {expandedSection?.subsections.map((section, idx) => ( ) - : null - } - active={currentView.route === section.route} + ref={el => (subsectionRefs.current[idx] = el)} + active={currentView?.route === section.route} to={section.route} exact={section.exact} key={section.title} tabIndex={0} role="button" + onKeyDown={e => handleKeyDown(e, idx, true)} > {section.title} ))} - {/* TODO(rudream): Figure out best place for for license footers. - - {cfg.edition === 'oss' && } - {cfg.edition === 'community' && } - */} ); } -const SubsectionItem = styled(NavLink)<{ active: boolean }>` +const SubsectionItem = React.forwardRef< + HTMLAnchorElement, + { + active: boolean; + to: string; + exact: boolean; + tabIndex: number; + role: string; + onKeyDown: (event: React.KeyboardEvent) => void; + children: React.ReactNode; + } +>((props, ref) => ); + +const StyledSubsectionItem = styled(NavLink)<{ active: boolean }>` display: flex; position: relative; color: ${props => props.theme.colors.text.slightlyMuted}; @@ -249,29 +304,28 @@ const SubsectionItem = styled(NavLink)<{ active: boolean }>` ${props => getSubsectionStyles(props.theme, props.active)} `; -function Section({ - section, - active, - setExpandedSection, - firstSubsectionItemRef, -}: { - section: NavigationSection; - active: boolean; - setExpandedSection: (category: NavigationSection) => void; - firstSubsectionItemRef: React.MutableRefObject; -}) { +const Section = React.forwardRef< + HTMLButtonElement, + { + section: NavigationSection; + active: boolean; + setExpandedSection: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; + } +>(({ section, active, setExpandedSection, onKeyDown }, ref) => { return ( setExpandedSection(section)} - onFocus={() => setExpandedSection(section)} - onKeyUp={e => e.key === 'Enter' && firstSubsectionItemRef.current.focus()} + onMouseEnter={setExpandedSection} + onFocus={setExpandedSection} + onKeyDown={onKeyDown} > {section.category} ); -} +}); const CategoryButton = styled.button<{ active: boolean }>` height: 60px; From 02785012c3cd2ba8eeddb5e203b37f05ef6c1de5 Mon Sep 17 00:00:00 2001 From: Yassine Bounekhla Date: Fri, 20 Sep 2024 07:04:57 -0400 Subject: [PATCH 4/4] add discover --- .../SideNavigation/CategoryIcon.tsx | 2 + .../Navigation/SideNavigation/Navigation.tsx | 40 ++++++++++++++----- .../Navigation/SideNavigation/categories.ts | 3 ++ web/packages/teleport/src/features.tsx | 1 + web/packages/teleport/src/types.ts | 2 + 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx b/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx index dd33214f7426..0ac5f0bf8be8 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/CategoryIcon.tsx @@ -34,6 +34,8 @@ export function CategoryIcon({ category }: { category: NavigationCategory }) { return ; case NavigationCategory.Audit: return ; + case NavigationCategory.AddNew: + return ; default: return null; } diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx index b623ef0a4cda..bc65d0824743 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx @@ -28,7 +28,11 @@ import cfg from 'teleport/config'; import { useFeatures } from 'teleport/FeaturesContext'; import { zIndexMap } from 'teleport/Main'; -import { NavigationCategory, NAVIGATION_CATEGORIES } from './categories'; +import { + NavigationCategory, + NAVIGATION_CATEGORIES, + STANDALONE_CATEGORIES, +} from './categories'; import { CategoryIcon } from './CategoryIcon'; import type * as history from 'history'; @@ -93,6 +97,7 @@ const RightPanel = styled(Box).attrs({ pt: 2, pb: 4, px: 2 })<{ type NavigationSection = { category: NavigationCategory; subsections: NavigationSubsection[]; + standalone?: boolean; }; type NavigationSubsection = { @@ -110,6 +115,7 @@ function getNavigationSections( const navigationSections = NAVIGATION_CATEGORIES.map(category => ({ category, subsections: getSubsectionsForCategory(category, features), + standalone: STANDALONE_CATEGORIES.indexOf(category) !== -1, })); return navigationSections; @@ -151,7 +157,6 @@ function getNavSubsectionForRoute( ); if (!feature || !feature.sideNavCategory) { - console.log('TRUE'); return; } @@ -181,7 +186,12 @@ export function Navigation() { const subsectionRefs = useRef>([]); const handleKeyDown = useCallback( - (event: React.KeyboardEvent, index: number, isSubsection: boolean) => { + ( + event: React.KeyboardEvent, + index: number, + isSubsection: boolean, + section: NavigationSection + ) => { if (event.key === 'Tab') { if (!isSubsection) return; @@ -203,8 +213,13 @@ export function Navigation() { sectionRefs.current[nextSectionIndex]?.focus(); } } else if (event.key === 'Enter' && !isSubsection) { - event.preventDefault(); - subsectionRefs.current[0]?.focus(); + if (section?.standalone) { + event.preventDefault(); + history.push(section.subsections[0].route); + } else { + event.preventDefault(); + subsectionRefs.current[0]?.focus(); + } } }, [expandedSection, expandedSectionIndex, navSections.length] @@ -241,12 +256,17 @@ export function Navigation() { setExpandedSection={() => handleSetExpandedSection(section, index)} aria-controls={`panel-${expandedSection?.category}`} ref={el => (sectionRefs.current[index] = el)} - onKeyDown={e => handleKeyDown(e, index, false)} + onKeyDown={e => handleKeyDown(e, index, false, section)} + onClick={() => { + if (section.standalone) { + history.push(section.subsections[0].route); + } + }} /> ))} @@ -263,7 +283,7 @@ export function Navigation() { key={section.title} tabIndex={0} role="button" - onKeyDown={e => handleKeyDown(e, idx, true)} + onKeyDown={e => handleKeyDown(e, idx, true, null)} > {section.title} @@ -311,8 +331,9 @@ const Section = React.forwardRef< active: boolean; setExpandedSection: () => void; onKeyDown: (event: React.KeyboardEvent) => void; + onClick: (event: React.MouseEvent) => void; } ->(({ section, active, setExpandedSection, onKeyDown }, ref) => { +>(({ section, active, setExpandedSection, onKeyDown, onClick }, ref) => { return ( {section.category} diff --git a/web/packages/teleport/src/Navigation/SideNavigation/categories.ts b/web/packages/teleport/src/Navigation/SideNavigation/categories.ts index fd34b94214fb..ba091059881c 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/categories.ts +++ b/web/packages/teleport/src/Navigation/SideNavigation/categories.ts @@ -31,4 +31,7 @@ export const NAVIGATION_CATEGORIES = [ NavigationCategory.Identity, NavigationCategory.Policy, NavigationCategory.Audit, + NavigationCategory.AddNew, ]; + +export const STANDALONE_CATEGORIES = [NavigationCategory.AddNew]; diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 318827c4699c..4505b75761fe 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -369,6 +369,7 @@ export class FeatureDiscover implements TeleportFeature { category = NavigationCategory.Management; section = ManagementSection.Access; sideNavCategory = SideNavigationCategory.AddNew; + standalone = true; route = { title: 'Enroll New Resource', diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index 7b91411cd963..4d75a4efaaea 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -110,6 +110,8 @@ export interface TeleportFeature { category?: NavigationCategory; // TODO(rudream): Delete category field above and rename sideNavCategory field to category once old nav is removed. sideNavCategory?: SideNavigationCategory; + /** standalone is whether this feature has no subsections */ + standalone?: boolean; section?: ManagementSection; hasAccess(flags: FeatureFlags): boolean; // logoOnlyTopbar is used to optionally hide the elements in the topbar from view except for the logo.