From dbf356b09f1e36f072af1bc94b59effc1119dfa6 Mon Sep 17 00:00:00 2001
From: Yassine Bounekhla <56373201+rudream@users.noreply.github.com>
Date: Mon, 28 Oct 2024 03:44:06 -0400
Subject: [PATCH] [Web] Add resource kind-specific sidenav sections (#47882)
* add resource kind-specific nav sections
* update eref
---
e | 2 +-
.../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 +-
.../encodeUrlQueryParams.test.ts | 15 +-
.../useUrlFiltering/encodeUrlQueryParams.ts | 2 +-
.../hooks/useUrlFiltering/useUrlFiltering.ts | 15 +-
web/packages/teleport/src/features.tsx | 5 +-
10 files changed, 439 insertions(+), 28 deletions(-)
create mode 100644 web/packages/teleport/src/Navigation/SideNavigation/ResourcesSection.tsx
diff --git a/e b/e
index fc67d20733894..03e3eb1845e67 160000
--- a/e
+++ b/e
@@ -1 +1 @@
-Subproject commit fc67d20733894f45e37aeebba64494085ef7667c
+Subproject commit 03e3eb1845e6780f764c5c8164dd55232714b6d4
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..dbcfbdc903195
--- /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: 'SSH Resources',
+ icon: Icons.Server,
+ route: nodesOnlyRoute,
+ searchableTags: ['resources', 'servers', 'nodes', 'ssh resources'],
+ 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..118fc905f7f79 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[] = [
+ getResourcesSectionForSearch({
+ clusterId,
+ preferences,
+ updatePreferences,
+ searchParams,
+ }),
+ ...navigationSections,
+ ];
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.test.ts b/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.test.ts
index f68e5a0ca5733..56ac3f40c6358 100644
--- a/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.test.ts
+++ b/web/packages/teleport/src/components/hooks/useUrlFiltering/encodeUrlQueryParams.test.ts
@@ -29,17 +29,17 @@ const testCases: {
{
title: 'No query params',
args: { pathname: '/foo' },
- expected: '/foo',
+ expected: '/foo?pinnedOnly=false',
},
{
title: 'Search string',
args: { pathname: '/test', searchString: 'something' },
- expected: '/test?search=something',
+ expected: '/test?search=something&pinnedOnly=false',
},
{
title: 'Search string, encoded',
args: { pathname: '/test', searchString: 'a$b$c' },
- expected: '/test?search=a%24b%24c',
+ expected: '/test?search=a%24b%24c&pinnedOnly=false',
},
{
title: 'Advanced search',
@@ -48,7 +48,7 @@ const testCases: {
searchString: 'foo=="bar"',
isAdvancedSearch: true,
},
- expected: '/test?query=foo%3D%3D%22bar%22',
+ expected: '/test?query=foo%3D%3D%22bar%22&pinnedOnly=false',
},
{
title: 'Search and sort',
@@ -57,7 +57,7 @@ const testCases: {
searchString: 'foobar',
sort: { fieldName: 'name', dir: 'ASC' },
},
- expected: '/test?search=foobar&sort=name%3Aasc',
+ expected: '/test?search=foobar&sort=name%3Aasc&pinnedOnly=false',
},
{
title: 'Sort only',
@@ -65,7 +65,7 @@ const testCases: {
pathname: '/test',
sort: { fieldName: 'name', dir: 'ASC' },
},
- expected: '/test?sort=name%3Aasc',
+ expected: '/test?sort=name%3Aasc&pinnedOnly=false',
},
{
title: 'Search, sort, and filter by kind',
@@ -75,7 +75,8 @@ const testCases: {
sort: { fieldName: 'name', dir: 'DESC' },
kinds: ['db', 'node'],
},
- expected: '/test?search=foo&sort=name%3Adesc&kinds=db&kinds=node',
+ expected:
+ '/test?search=foo&sort=name%3Adesc&pinnedOnly=false&kinds=db&kinds=node',
},
];
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..f1aab44471ad5 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', 'resources'],
};
hasAccess(flags: FeatureFlags) {
@@ -480,6 +480,7 @@ export class FeatureIntegrationEnroll implements TeleportFeature {
getLink() {
return cfg.getIntegrationEnrollRoute(null);
},
+ searchableTags: ['new', 'add', 'enroll', 'integration'],
};
// getRoute allows child class extending this
@@ -593,7 +594,7 @@ export class FeatureTrust implements TeleportFeature {
getLink() {
return cfg.routes.trustedClusters;
},
- searchableTags: ['clusters', 'trusted clusters'],
+ searchableTags: ['clusters', 'trusted clusters', 'root clusters'],
};
}