From 20d783b80238ee48fb5f9f55230ace3b25dec208 Mon Sep 17 00:00:00 2001 From: Yassine Bounekhla Date: Mon, 28 Oct 2024 02:55:43 -0400 Subject: [PATCH] add resource kind-specific nav sections --- .../Navigation/SideNavigation/Navigation.tsx | 24 +- .../SideNavigation/ResourcesSection.tsx | 349 ++++++++++++++++++ .../src/Navigation/SideNavigation/Search.tsx | 33 +- .../src/Navigation/SideNavigation/Section.tsx | 4 +- .../Navigation/SideNavigation/categories.ts | 18 +- .../useUrlFiltering/encodeUrlQueryParams.ts | 2 +- .../hooks/useUrlFiltering/useUrlFiltering.ts | 15 +- web/packages/teleport/src/features.tsx | 2 +- 8 files changed, 428 insertions(+), 19 deletions(-) create mode 100644 web/packages/teleport/src/Navigation/SideNavigation/ResourcesSection.tsx diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx index 2b532b44e86ac..2099a09abd61f 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx @@ -36,11 +36,12 @@ import { import { zIndexMap } from './zIndexMap'; import { + CustomNavigationSubcategory, NAVIGATION_CATEGORIES, - STANDALONE_CATEGORIES, SidenavCategory, } from './categories'; import { SearchSection } from './Search'; +import { ResourcesSection } from './ResourcesSection'; import type * as history from 'history'; import type { TeleportFeature } from 'teleport/types'; @@ -86,6 +87,20 @@ export type NavigationSubsection = { icon: (props) => JSX.Element; parent?: TeleportFeature; searchableTags?: string[]; + /** + * customRouteMatchFn is a custom function for determining whether this subsection is currently active, + * this is useful in cases where a simple base route match isn't sufficient. + */ + customRouteMatchFn?: (currentViewRoute: string) => boolean; + /** + * subCategory is the subcategory (ie. subsection grouping) this subsection should be under, if applicable. + * */ + subCategory?: CustomNavigationSubcategory; + /** + * onClick is custom code that can be run when clicking on the subsection. + * Note that this is merely extra logic, and does not replace the default routing behaviour of a subsection which will navigate the user to the route. + */ + onClick?: () => void; }; function getNavigationSections( @@ -94,7 +109,6 @@ function getNavigationSections( const navigationSections = NAVIGATION_CATEGORIES.map(category => ({ category, subsections: getSubsectionsForCategory(category, features), - standalone: STANDALONE_CATEGORIES.indexOf(category) !== -1, })); return navigationSections; @@ -292,6 +306,12 @@ export function Navigation() { handleSetExpandedSection={handleSetExpandedSection} currentView={currentView} /> + {navSections.map(section => ( {section.category === 'Add New' && } diff --git a/web/packages/teleport/src/Navigation/SideNavigation/ResourcesSection.tsx b/web/packages/teleport/src/Navigation/SideNavigation/ResourcesSection.tsx new file mode 100644 index 0000000000000..169d6da41e535 --- /dev/null +++ b/web/packages/teleport/src/Navigation/SideNavigation/ResourcesSection.tsx @@ -0,0 +1,349 @@ +/** + * 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 styled from 'styled-components'; +import { matchPath } from 'react-router'; + +import { Box, Flex, Text } from 'design'; +import * as Icons from 'design/Icon'; + +import { DefaultTab } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; +import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; + +import cfg from 'teleport/config'; +import useStickyClusterId from 'teleport/useStickyClusterId'; +import { encodeUrlQueryParams } from 'teleport/components/hooks/useUrlFiltering'; +import { EncodeUrlQueryParamsProps } from 'teleport/components/hooks/useUrlFiltering/encodeUrlQueryParams'; +import { ResourceIdKind } from 'teleport/services/agents'; +import { useUser } from 'teleport/User/UserContext'; + +import { NavigationSubsection, NavigationSection } from './Navigation'; +import { + Section, + RightPanel, + verticalPadding, + SubsectionItem, +} from './Section'; +import { CustomNavigationSubcategory, NavigationCategory } from './categories'; + +/** + * getResourcesSectionForSearch returns a NavigationSection for resources, + * this is only used for the sake of indexing these subsections in the sidenav search. + */ +export function getResourcesSectionForSearch( + subsectionProps: GetSubsectionProps +): NavigationSection { + return { + category: NavigationCategory.Resources, + subsections: getResourcesSubsections(subsectionProps), + }; +} + +type GetSubsectionProps = { + clusterId: string; + preferences: UserPreferences; + updatePreferences: (preferences: Partial) => Promise; + searchParams: URLSearchParams; +}; + +function encodeUrlQueryParamsWithTypedKinds( + params: Omit & { + kinds?: ResourceIdKind[]; + } +) { + return encodeUrlQueryParams(params); +} + +function getResourcesSubsections({ + clusterId, + preferences, + updatePreferences, + searchParams, +}: GetSubsectionProps): NavigationSubsection[] { + const baseRoute = cfg.getUnifiedResourcesRoute(clusterId); + + const setPinnedUserPreference = (pinnedOnly: boolean) => { + // Return early if the current user preference already matches the pinnedOnly param provided, since nothing needs to be done. + if ( + (pinnedOnly && + preferences?.unifiedResourcePreferences?.defaultTab === + DefaultTab.PINNED) || + (!pinnedOnly && + (preferences?.unifiedResourcePreferences?.defaultTab === + DefaultTab.ALL || + preferences?.unifiedResourcePreferences?.defaultTab === + DefaultTab.UNSPECIFIED)) + ) { + return; + } + + updatePreferences({ + ...preferences, + unifiedResourcePreferences: { + ...preferences?.unifiedResourcePreferences, + defaultTab: pinnedOnly ? DefaultTab.PINNED : DefaultTab.ALL, + }, + }); + }; + + const currentKinds = searchParams + .getAll('kinds') + .flatMap(k => k.split(',')) + .filter(Boolean); + const isPinnedOnly = + preferences?.unifiedResourcePreferences.defaultTab === DefaultTab.PINNED; + + // isKindActive returns true if we are currently filtering for only the provided kind of resource. + const isKindActive = (kind: ResourceIdKind) => { + // This subsection for this kind should only be marked active when it is the only kind being filtered for, + // if there are multiple kinds then the "All Resources" button should be active. + return currentKinds.length === 1 && currentKinds[0] === kind; + }; + + const allResourcesRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + pinnedOnly: false, + }); + const pinnedOnlyRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + pinnedOnly: true, + }); + const applicationsOnlyRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + kinds: ['app'], + pinnedOnly: false, + }); + const databasesOnlyRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + kinds: ['db'], + pinnedOnly: false, + }); + const desktopsOnlyRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + kinds: ['windows_desktop'], + pinnedOnly: false, + }); + const kubesOnlyRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + kinds: ['kube_cluster'], + pinnedOnly: false, + }); + const nodesOnlyRoute = encodeUrlQueryParamsWithTypedKinds({ + pathname: baseRoute, + kinds: ['node'], + pinnedOnly: false, + }); + + return [ + { + title: 'All Resources', + icon: Icons.Server, + route: allResourcesRoute, + searchableTags: ['resources', 'resources', 'all resources'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: currentViewRoute => + !!matchPath(currentViewRoute, { + path: cfg.routes.unifiedResources, + exact: false, + }) && + !isPinnedOnly && + currentKinds.length !== 1, + onClick: () => setPinnedUserPreference(false), + }, + { + title: 'Pinned Resources', + icon: Icons.PushPin, + route: pinnedOnlyRoute, + searchableTags: ['resources', 'resources', 'pinned resources'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: () => isPinnedOnly && currentKinds.length !== 1, + onClick: () => setPinnedUserPreference(true), + }, + { + title: 'Applications', + icon: Icons.Application, + route: applicationsOnlyRoute, + searchableTags: ['resources', 'apps', 'applications'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: () => isKindActive('app'), + onClick: () => setPinnedUserPreference(false), + subCategory: CustomNavigationSubcategory.FilteredViews, + }, + { + title: 'Databases', + icon: Icons.Database, + route: databasesOnlyRoute, + searchableTags: ['resources', 'dbs', 'databases'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: () => isKindActive('db'), + onClick: () => setPinnedUserPreference(false), + subCategory: CustomNavigationSubcategory.FilteredViews, + }, + { + title: 'Desktops', + icon: Icons.Database, + route: desktopsOnlyRoute, + searchableTags: ['resources', 'desktops', 'rdp', 'windows'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: () => isKindActive('windows_desktop'), + onClick: () => setPinnedUserPreference(false), + subCategory: CustomNavigationSubcategory.FilteredViews, + }, + { + title: 'Kubernetes', + icon: Icons.Kubernetes, + route: kubesOnlyRoute, + searchableTags: ['resources', 'k8s', 'kubes', 'kubernetes'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: () => isKindActive('kube_cluster'), + onClick: () => setPinnedUserPreference(false), + subCategory: CustomNavigationSubcategory.FilteredViews, + }, + { + title: 'Servers', + icon: Icons.Server, + route: nodesOnlyRoute, + searchableTags: ['resources', 'servers', 'nodes', 'ssh'], + category: NavigationCategory.Resources, + exact: false, + customRouteMatchFn: () => isKindActive('node'), + onClick: () => setPinnedUserPreference(false), + subCategory: CustomNavigationSubcategory.FilteredViews, + }, + ]; +} + +export function ResourcesSection({ + expandedSection, + previousExpandedSection, + handleSetExpandedSection, + currentView, +}: { + expandedSection: NavigationSection; + previousExpandedSection: NavigationSection; + currentView: NavigationSubsection; + handleSetExpandedSection: (section: NavigationSection) => void; +}) { + const { clusterId } = useStickyClusterId(); + const { preferences, updatePreferences } = useUser(); + const section: NavigationSection = { + category: NavigationCategory.Resources, + subsections: [], + }; + const baseRoute = cfg.getUnifiedResourcesRoute(clusterId); + + const searchParams = new URLSearchParams(location.search); + + const isExpanded = expandedSection?.category === NavigationCategory.Resources; + + const subsections = getResourcesSubsections({ + clusterId, + preferences, + updatePreferences, + searchParams, + }); + + const currentViewRoute = currentView?.route; + + return ( +
null} + setExpandedSection={() => handleSetExpandedSection(section)} + aria-controls={`panel-${expandedSection?.category}`} + isExpanded={isExpanded} + > + handleSetExpandedSection(section)} + > + + + + Resources + + + {subsections + .filter(section => !section.subCategory) + .map(section => ( + + + {section.title} + + ))} + + + + + Filtered Views + + + + {subsections + .filter( + section => + section.subCategory === + CustomNavigationSubcategory.FilteredViews + ) + .map(section => ( + + + {section.title} + + ))} + + +
+ ); +} + +export const Divider = styled.div` + height: 1px; + width: 100%; + background: ${props => props.theme.colors.interactive.tonal.neutral[1]}; + margin: ${props => props.theme.space[1]}px 0px + ${props => props.theme.space[1]}px 0px; +`; diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Search.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Search.tsx index 9ca0c39aab873..88ca8c6b0af48 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Search.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Search.tsx @@ -23,6 +23,9 @@ import styled from 'styled-components'; import { Box, Flex, Text } from 'design'; import { height, space, color } from 'design/system'; +import useStickyClusterId from 'teleport/useStickyClusterId'; +import { useUser } from 'teleport/User/UserContext'; + import { NavigationSection, NavigationSubsection } from './Navigation'; import { Section, @@ -32,6 +35,7 @@ import { } from './Section'; import { CategoryIcon } from './CategoryIcon'; import { CustomNavigationCategory } from './categories'; +import { getResourcesSectionForSearch } from './ResourcesSection'; export function SearchSection({ navigationSections, @@ -50,6 +54,20 @@ export function SearchSection({ category: CustomNavigationCategory.Search, subsections: [], }; + const { clusterId } = useStickyClusterId(); + const { preferences, updatePreferences } = useUser(); + + const searchParams = new URLSearchParams(location.search); + + const searchableNavSections: NavigationSection[] = [ + ...navigationSections, + getResourcesSectionForSearch({ + clusterId, + preferences, + updatePreferences, + searchParams, + }), + ]; const isExpanded = expandedSection?.category === CustomNavigationCategory.Search; @@ -70,7 +88,7 @@ export function SearchSection({ onFocus={() => handleSetExpandedSection(section)} > @@ -123,7 +141,11 @@ function SearchContent({ ))} @@ -141,7 +163,12 @@ function SearchResult({ $active: boolean; }) { return ( - + ` display: flex; diff --git a/web/packages/teleport/src/Navigation/SideNavigation/categories.ts b/web/packages/teleport/src/Navigation/SideNavigation/categories.ts index f1692be3df123..6bc60bd20f818 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/categories.ts +++ b/web/packages/teleport/src/Navigation/SideNavigation/categories.ts @@ -25,23 +25,27 @@ export enum NavigationCategory { AddNew = 'Add New', } -/* CustomNavigationCategory are pseudo-categories which exist only in the nav menu, eg. Search. */ +/** + * CustomNavigationCategory are pseudo-categories which exist only in the nav menu, eg. Search. + */ export enum CustomNavigationCategory { Search = 'Search', } +/** + * CustomNavigationSubcategory are subcategories within a navigation category which can be used to + * create groupings of subsections, eg. Filtered Views. + */ +export enum CustomNavigationSubcategory { + FilteredViews = 'Filtered Views', +} + export type SidenavCategory = NavigationCategory | CustomNavigationCategory; export const NAVIGATION_CATEGORIES = [ - NavigationCategory.Resources, NavigationCategory.Access, NavigationCategory.Identity, NavigationCategory.Policy, NavigationCategory.Audit, NavigationCategory.AddNew, ]; - -export const STANDALONE_CATEGORIES = [ - // TODO(rudream): Remove this once shortcuts to pinned/nodes/apps/dbs/desktops/kubes are implemented. - NavigationCategory.Resources, -]; diff --git a/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.ts b/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.ts index a00b57dc47695..fbcbf0217f917 100644 --- a/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.ts +++ b/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.ts @@ -45,7 +45,7 @@ export function encodeUrlQueryParams({ urlParams.append('sort', `${sort.fieldName}:${sort.dir.toLowerCase()}`); } - if (pinnedOnly) { + if (pinnedOnly !== undefined) { urlParams.append('pinnedOnly', `${pinnedOnly}`); } diff --git a/web/packages/teleport/src/components/hooks/useUrlFiltering/useUrlFiltering.ts b/web/packages/teleport/src/components/hooks/useUrlFiltering/useUrlFiltering.ts index 37652913fcb21..fa2bc1ce175a1 100644 --- a/web/packages/teleport/src/components/hooks/useUrlFiltering/useUrlFiltering.ts +++ b/web/packages/teleport/src/components/hooks/useUrlFiltering/useUrlFiltering.ts @@ -62,8 +62,16 @@ export function useUrlFiltering( const [initialParamsState] = useState(initialParams); const params = useMemo(() => { - return { ...initialParamsState, ...getResourceUrlQueryParams(search) }; - }, [initialParamsState, search]); + const urlParams = getResourceUrlQueryParams(search); + return { + ...initialParamsState, + ...urlParams, + pinnedOnly: + urlParams.pinnedOnly !== undefined + ? urlParams.pinnedOnly + : initialParamsState.pinnedOnly, + }; + }, [search]); function setParams(newParams: ResourceFilter) { replaceHistory( @@ -134,6 +142,7 @@ export default function getResourceUrlQueryParams( // Conditionally adds the sort field based on whether it exists or not ...(!!processedSortParam && { sort: processedSortParam }), // Conditionally adds the pinnedResources field based on whether its true or not - ...(pinnedOnly === 'true' && { pinnedOnly: true }), + pinnedOnly: + pinnedOnly === 'true' ? true : pinnedOnly === 'false' ? false : undefined, }; } diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 807b257f11cf8..b8248a7bbd904 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -415,7 +415,7 @@ export class FeatureDiscover implements TeleportFeature { getLink() { return cfg.routes.discover; }, - searchableTags: ['new resource', 'add'], + searchableTags: ['new', 'add', 'enroll'], }; hasAccess(flags: FeatureFlags) {