From 750bdf65fb76c3d693f78a738c3649c7e3745c20 Mon Sep 17 00:00:00 2001
From: Yassine Bounekhla <56373201+rudream@users.noreply.github.com>
Date: Wed, 30 Oct 2024 17:29:44 -0400
Subject: [PATCH] add recent history to sidenav (#47942)
---
.../teleport/src/Navigation/RecentHistory.tsx | 300 ++++++++++++++++++
.../Navigation/SideNavigation/Navigation.tsx | 59 +++-
.../SideNavigation/ResourcesSection.tsx | 2 +-
.../src/Navigation/SideNavigation/Search.tsx | 52 ++-
web/packages/teleport/src/features.tsx | 8 +
.../services/storageService/storageService.ts | 45 +++
.../src/services/storageService/types.ts | 1 +
7 files changed, 446 insertions(+), 21 deletions(-)
create mode 100644 web/packages/teleport/src/Navigation/RecentHistory.tsx
diff --git a/web/packages/teleport/src/Navigation/RecentHistory.tsx b/web/packages/teleport/src/Navigation/RecentHistory.tsx
new file mode 100644
index 0000000000000..38ae478bd6b2a
--- /dev/null
+++ b/web/packages/teleport/src/Navigation/RecentHistory.tsx
@@ -0,0 +1,300 @@
+/**
+ * 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, useEffect, useRef } from 'react';
+import styled from 'styled-components';
+import { matchPath } from 'react-router';
+import { NavLink } from 'react-router-dom';
+import { Flex, Text, H4, P3, ButtonIcon } from 'design';
+import { Cross } from 'design/Icon';
+
+import { TeleportFeature } from 'teleport/types';
+import { useFeatures } from 'teleport/FeaturesContext';
+
+import { getSubsectionStyles } from './SideNavigation/Section';
+import { SidenavCategory } from './SideNavigation/categories';
+
+export type RecentHistoryItem = {
+ category?: SidenavCategory;
+ title: string;
+ route: string;
+ exact?: boolean;
+};
+
+type AnimatedItem = RecentHistoryItem & {
+ animationState: 'exiting' | 'entering' | '';
+};
+
+function getIconForRoute(
+ features: TeleportFeature[],
+ route: string
+): (props) => JSX.Element {
+ const feature = features.find(feature =>
+ matchPath(route, {
+ path: feature?.route?.path,
+ exact: false,
+ })
+ );
+
+ const icon = feature?.navigationItem?.icon || feature?.topMenuItem?.icon;
+ if (!icon) {
+ return () => null;
+ }
+
+ return icon;
+}
+
+export function RecentHistory({
+ recentHistoryItems,
+ onRemoveItem,
+}: {
+ recentHistoryItems: RecentHistoryItem[];
+ onRemoveItem: (route: string) => void;
+}) {
+ const features = useFeatures();
+ const [animatedItems, setAnimatedItems] = useState([]);
+ const prevItemsRef = useRef([]);
+
+ useEffect(() => {
+ const prevItems = prevItemsRef.current;
+ let newAnimatedItems = recentHistoryItems.map(item => ({
+ ...item,
+ animationState: '',
+ })) as AnimatedItem[];
+
+ const isFirstItemDeleted =
+ recentHistoryItems.findIndex(
+ item => item.route === prevItems[0]?.route
+ ) === -1;
+
+ // If an item the previous list is not in the new list (deleted) OR was moved, animate it out.
+ prevItems.forEach((prevItem, index) => {
+ if (
+ !recentHistoryItems.some(item => item.route === prevItem.route) ||
+ (prevItem?.route !== recentHistoryItems[index]?.route &&
+ recentHistoryItems[0]?.route === prevItem?.route)
+ ) {
+ // If the item is now in the first position (meaning it was moved to the top), animate it in at the top in addition to animating it out in its previous position.
+ if (
+ recentHistoryItems.length > 0 &&
+ prevItems[0]?.route !== recentHistoryItems[0]?.route &&
+ !isFirstItemDeleted
+ ) {
+ newAnimatedItems.splice(0, 1);
+ newAnimatedItems = [
+ { ...prevItem, animationState: 'entering' },
+ ...newAnimatedItems.slice(0, index),
+ { ...prevItem, animationState: 'exiting' },
+ ...newAnimatedItems.slice(index),
+ ];
+ } else if (
+ !recentHistoryItems.some(item => item.route === prevItem.route)
+ ) {
+ newAnimatedItems = [
+ ...newAnimatedItems.slice(0, index),
+ { ...prevItem, animationState: 'exiting' },
+ ...newAnimatedItems.slice(index),
+ ];
+ }
+ }
+ });
+
+ setAnimatedItems(newAnimatedItems);
+ prevItemsRef.current = recentHistoryItems;
+
+ // Clean up animated items after animation
+ const deletedItemTimeout = setTimeout(() => {
+ setAnimatedItems(items =>
+ items.filter(item => item.animationState !== 'exiting')
+ );
+ }, 300);
+ const newItemsTimeout = setTimeout(() => {
+ setAnimatedItems(items =>
+ items.map(item => ({ ...item, animationState: '' }))
+ );
+ }, 400);
+
+ return () => {
+ clearTimeout(deletedItemTimeout);
+ clearTimeout(newItemsTimeout);
+ };
+ }, [recentHistoryItems]);
+
+ return (
+
+
+ Recent Pages
+
+ {!!animatedItems.length && (
+
+ {animatedItems.map((item, index) => {
+ const Icon = getIconForRoute(features, item.route);
+ return (
+ onRemoveItem(item.route)}
+ />
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function AnimatedHistoryItem({
+ item,
+ Icon,
+ onRemove,
+}: {
+ item: AnimatedItem;
+ Icon: (props) => JSX.Element;
+ onRemove: () => void;
+}) {
+ const [hovered, setHovered] = useState(false);
+ const itemRef = useRef(null);
+
+ useEffect(() => {
+ if (item.animationState === 'exiting' && itemRef.current) {
+ const height = item.category ? 60 : 40;
+ itemRef.current.style.height = `${height}px`;
+ itemRef.current.style.opacity = '1';
+ itemRef.current.offsetHeight; // Force reflow
+ requestAnimationFrame(() => {
+ if (itemRef.current) {
+ itemRef.current.style.height = '0px';
+ itemRef.current.style.opacity = '0';
+ }
+ });
+ }
+
+ if (item.animationState === 'entering' && itemRef.current) {
+ const height = item.category ? 60 : 40;
+ itemRef.current.style.height = `0px`;
+ itemRef.current.style.opacity = '0';
+ itemRef.current.offsetHeight; // Force reflow
+ requestAnimationFrame(() => {
+ if (itemRef.current) {
+ itemRef.current.style.height = `${height}px`;
+ itemRef.current.style.opacity = '1';
+ }
+ });
+ }
+ }, [item.animationState]);
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onMouseOver={() => setHovered(true)}
+ style={{ height: item.animationState === 'entering' ? 0 : 'auto' }}
+ >
+
+
+
+
+
+
+
+ {item.title}
+
+ {item.category && {item.category}}
+
+
+ {hovered && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={e => {
+ e.stopPropagation();
+ e.preventDefault();
+ onRemove();
+ }}
+ >
+
+
+ )}
+
+
+ );
+}
+
+const AnimatedItemWrapper = styled.div<{
+ isExiting: boolean;
+ isEntering: boolean;
+}>`
+ overflow: hidden;
+ height: auto;
+ width: 100%;
+ transition: all 0.3s ease-in-out;
+ padding: 3px;
+
+ ${props =>
+ props.isEntering &&
+ `
+ transition: all 0.3s ease-in-out 0.1s;
+ pointer-events: none;
+ opacity: 0;
+ `}
+
+ ${props =>
+ props.isExiting &&
+ `
+ pointer-events: none;
+ `}
+`;
+
+const StyledNavLink = styled(NavLink)`
+ padding: ${props => props.theme.space[2]}px ${props => props.theme.space[3]}px;
+ text-decoration: none;
+ user-select: none;
+ border-radius: ${props => props.theme.radii[2]}px;
+ max-width: 100%;
+ display: flex;
+ position: relative;
+
+ cursor: pointer;
+
+ ${props => getSubsectionStyles(props.theme, false)}
+`;
+
+const DeleteButtonAlt = styled(ButtonIcon)<{ size: number }>`
+ position: absolute;
+ right: 0;
+ top: 0;
+ height: ${props => props.size}px;
+ width: ${props => props.size}px;
+ border-radius: ${props => props.theme.radii[2]}px;
+`;
diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx
index 2099a09abd61f..9ba01936767d4 100644
--- a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx
+++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx
@@ -16,7 +16,13 @@
* along with this program. If not, see .
*/
-import React, { useState, useCallback, useEffect, useRef } from 'react';
+import React, {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ useMemo,
+} from 'react';
import styled, { useTheme } from 'styled-components';
import { matchPath, useHistory } from 'react-router';
import { Text, Flex, Box, P2 } from 'design';
@@ -72,15 +78,18 @@ const PanelBackground = styled.div`
/* NavigationSection is a section in the navbar, this can either be a standalone section (clickable button with no drawer), or a category with subsections shown in a drawer that expands. */
export type NavigationSection = {
- category: SidenavCategory;
+ category?: SidenavCategory;
subsections?: NavigationSubsection[];
/* standalone is whether this is a clickable nav section with no subsections/drawer. */
standalone?: boolean;
};
-/* NavigationSubsection is a subsection of a NavigationSection, these are the items listed in the drawer of a NavigationSection. */
+/**
+ * NavigationSubsection is a subsection of a NavigationSection, these are the items listed in the drawer of a NavigationSection, or if isTopMenuItem is true, in the top menu (eg. Account Settings).
+ */
export type NavigationSubsection = {
- category: SidenavCategory;
+ category?: SidenavCategory;
+ isTopMenuItem?: boolean;
title: string;
route: string;
exact: boolean;
@@ -139,6 +148,26 @@ function getSubsectionsForCategory(
// getNavSubsectionForRoute returns the sidenav subsection that the user is correctly on (based on route).
// Note that it is possible for this not to return anything, such as in the case where the user is on a page that isn't in the sidenav (eg. Account Settings).
+/**
+ * getTopMenuSection returns a NavigationSection with the top menu items. This is not used in the sidenav, but will be used to make the top menu items searchable.
+ */
+function getTopMenuSection(features: TeleportFeature[]): NavigationSection {
+ const topMenuItems = features.filter(
+ feature => !!feature.topMenuItem && !feature.sideNavCategory
+ );
+
+ return {
+ subsections: topMenuItems.map(feature => ({
+ isTopMenuItem: true,
+ title: feature.topMenuItem.title,
+ route: feature.topMenuItem.getLink(cfg.proxyCluster),
+ exact: feature?.route?.exact,
+ icon: feature.topMenuItem.icon,
+ searchableTags: feature.topMenuItem.searchableTags,
+ })),
+ };
+}
+
function getNavSubsectionForRoute(
features: TeleportFeature[],
route: history.Location | Location
@@ -152,10 +181,22 @@ function getNavSubsectionForRoute(
})
);
- if (!feature || !feature.sideNavCategory) {
+ if (!feature || (!feature.sideNavCategory && !feature.topMenuItem)) {
return;
}
+ if (feature.topMenuItem) {
+ return {
+ isTopMenuItem: true,
+ exact: feature.route.exact,
+ title: feature.topMenuItem.title,
+ route: feature.topMenuItem.getLink(cfg.proxyCluster),
+ icon: feature.topMenuItem.icon,
+ searchableTags: feature.topMenuItem.searchableTags,
+ category: feature?.sideNavCategory,
+ };
+ }
+
return {
category: feature.sideNavCategory,
title: feature.navigationItem.title,
@@ -223,11 +264,15 @@ export function Navigation() {
}
};
}, []);
- const currentView = getNavSubsectionForRoute(features, history.location);
+ const currentView = useMemo(
+ () => getNavSubsectionForRoute(features, history.location),
+ [history.location]
+ );
const navSections = getNavigationSections(features).filter(
section => section.subsections.length
);
+ const topMenuSection = getTopMenuSection(features);
const handleSetExpandedSection = useCallback(
(section: NavigationSection) => {
@@ -300,7 +345,7 @@ export function Navigation() {
.
*/
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
-import { Box, Flex, Text } from 'design';
+import { Box, Flex, P3, Text } from 'design';
import { height, space, color } from 'design/system';
import useStickyClusterId from 'teleport/useStickyClusterId';
import { useUser } from 'teleport/User/UserContext';
+import { storageService } from 'teleport/services/storageService';
+
+import { RecentHistory, RecentHistoryItem } from '../RecentHistory';
import { NavigationSection, NavigationSubsection } from './Navigation';
import {
@@ -33,7 +36,6 @@ import {
verticalPadding,
getSubsectionStyles,
} from './Section';
-import { CategoryIcon } from './CategoryIcon';
import { CustomNavigationCategory } from './categories';
import { getResourcesSectionForSearch } from './ResourcesSection';
@@ -120,6 +122,28 @@ function SearchContent({
)
);
+ const [recentHistory, setRecentHistory] = useState(
+ storageService.getRecentHistory()
+ );
+
+ useEffect(() => {
+ if (currentView) {
+ const newRecentHistory = storageService.addRecentHistoryItem({
+ category: currentView?.category,
+ title: currentView?.title,
+ route: currentView?.route,
+ exact: currentView?.exact,
+ });
+
+ setRecentHistory(newRecentHistory);
+ }
+ }, [currentView]);
+
+ function handleRemoveItem(route: string) {
+ const newRecentHistory = storageService.removeRecentHistoryItem(route);
+ setRecentHistory(newRecentHistory);
+ }
+
return (
@@ -150,6 +174,12 @@ function SearchContent({
))}
)}
+ {searchInput.length === 0 && (
+
+ )}
);
@@ -170,20 +200,16 @@ function SearchResult({
onClick={subsection.onClick}
>
-
-
-
+
+
+
{subsection.title}
-
- {subsection.category}
-
+ {subsection.category && (
+ {subsection.category}
+ )}
diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx
index f1aab44471ad5..722d97fb565e3 100644
--- a/web/packages/teleport/src/features.tsx
+++ b/web/packages/teleport/src/features.tsx
@@ -645,6 +645,13 @@ export class FeatureAccount implements TeleportFeature {
getLink() {
return cfg.routes.account;
},
+ searchableTags: [
+ 'account settings',
+ 'settings',
+ 'password',
+ 'mfa',
+ 'change password',
+ ],
};
}
@@ -667,6 +674,7 @@ export class FeatureHelpAndSupport implements TeleportFeature {
getLink() {
return cfg.routes.support;
},
+ searchableTags: ['help', 'support', NavTitle.HelpAndSupport],
};
}
diff --git a/web/packages/teleport/src/services/storageService/storageService.ts b/web/packages/teleport/src/services/storageService/storageService.ts
index 92b9b3ec3e283..ebef656ff7b58 100644
--- a/web/packages/teleport/src/services/storageService/storageService.ts
+++ b/web/packages/teleport/src/services/storageService/storageService.ts
@@ -28,6 +28,7 @@ import {
convertBackendUserPreferences,
isBackendUserPreferences,
} from 'teleport/services/userPreferences/userPreferences';
+import { RecentHistoryItem } from 'teleport/Navigation/RecentHistory';
import { CloudUserInvites, KeysEnum, LocalStorageSurvey } from './types';
@@ -41,8 +42,11 @@ const KEEP_LOCALSTORAGE_KEYS_ON_LOGOUT = [
KeysEnum.LICENSE_ACKNOWLEDGED,
KeysEnum.USERS_NOT_EQUAL_TO_MAU_ACKNOWLEDGED,
KeysEnum.USE_NEW_ROLE_EDITOR,
+ KeysEnum.RECENT_HISTORY,
];
+const RECENT_HISTORY_MAX_LENGTH = 10;
+
export const storageService = {
clear() {
Object.keys(window.localStorage).forEach(key => {
@@ -265,4 +269,45 @@ export const storageService = {
getIsTopBarView(): boolean {
return this.getParsedJSONValue(KeysEnum.USE_TOP_BAR, false);
},
+
+ getRecentHistory(): RecentHistoryItem[] {
+ return this.getParsedJSONValue(KeysEnum.RECENT_HISTORY, []);
+ },
+
+ addRecentHistoryItem(item: RecentHistoryItem): RecentHistoryItem[] {
+ const history = storageService.getRecentHistory();
+ const deduplicatedHistory = [...history];
+
+ // Remove a duplicate item if it exists.
+ const existingDuplicateIndex = history.findIndex(
+ historyItem => historyItem.route === item.route
+ );
+ if (existingDuplicateIndex !== -1) {
+ deduplicatedHistory.splice(existingDuplicateIndex, 1);
+ }
+
+ const newHistory = [item, ...deduplicatedHistory].slice(
+ 0,
+ RECENT_HISTORY_MAX_LENGTH
+ );
+
+ window.localStorage.setItem(
+ KeysEnum.RECENT_HISTORY,
+ JSON.stringify(newHistory)
+ );
+
+ return newHistory;
+ },
+
+ removeRecentHistoryItem(route: string): RecentHistoryItem[] {
+ const history = storageService.getRecentHistory();
+ const newHistory = history.filter(item => item.route !== route);
+
+ window.localStorage.setItem(
+ KeysEnum.RECENT_HISTORY,
+ JSON.stringify(newHistory)
+ );
+
+ return newHistory;
+ },
};
diff --git a/web/packages/teleport/src/services/storageService/types.ts b/web/packages/teleport/src/services/storageService/types.ts
index f689442d13368..fa04cc88c704d 100644
--- a/web/packages/teleport/src/services/storageService/types.ts
+++ b/web/packages/teleport/src/services/storageService/types.ts
@@ -36,6 +36,7 @@ export const KeysEnum = {
USERS_NOT_EQUAL_TO_MAU_ACKNOWLEDGED:
'grv_users_not_equal_to_mau_acknowledged',
LOCAL_NOTIFICATION_STATES: 'grv_teleport_notification_states',
+ RECENT_HISTORY: 'grv_teleport_sidenav_recent_history',
// TODO(bl-nero): Remove once the new role editor is in acceptable state.
USE_NEW_ROLE_EDITOR: 'grv_teleport_use_new_role_editor',