diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index 6b87db9ea8..3d6b7a0f14 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -2,14 +2,18 @@ import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMe import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments'; import { Route, Routes, useNavigate } from 'react-router-dom'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; -import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { + FeatureStrategyEdit, + formatFeaturePath, +} from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments'; import { styled } from '@mui/material'; import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; -import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { useEffect } from 'react'; +import { useLastViewedFlags } from 'hooks/useLastViewedFlags'; const StyledContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -37,6 +41,10 @@ const FeatureOverview = () => { useHiddenEnvironments(); const onSidebarClose = () => navigate(featurePath); usePageTitle(featureId); + const { setLastViewed } = useLastViewedFlags(); + useEffect(() => { + setLastViewed({ featureId, projectId }); + }, [featureId]); return ( diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx index 9d65fd1737..49ed0dfedb 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx @@ -18,6 +18,7 @@ import Accordion from '@mui/material/Accordion'; import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import FlagIcon from '@mui/icons-material/OutlinedFlag'; const StyledBadgeContainer = styled('div')(({ theme }) => ({ paddingLeft: theme.spacing(2), @@ -119,6 +120,30 @@ export const RecentProjectsList: FC<{ ); }; +export const RecentFlagsList: FC<{ + flags: { featureId: string; projectId: string }[]; + mode: NavigationMode; + onClick: () => void; +}> = ({ flags, mode, onClick }) => { + const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; + + return ( + + {flags.map((flag) => ( + + + + ))} + + ); +}; + export const PrimaryNavigationList: FC<{ mode: NavigationMode; onClick: (activeItem: string) => void; @@ -226,3 +251,22 @@ export const RecentProjectsNavigation: FC<{ ); }; + +export const RecentFlagsNavigation: FC<{ + mode: NavigationMode; + flags: { featureId: string; projectId: string }[]; + onClick: () => void; +}> = ({ mode, onClick, flags }) => { + return ( + + {mode === 'full' && ( + + Recent flags + + )} + + + ); +}; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx index 4a65500afa..9c247090f2 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx @@ -4,6 +4,12 @@ import { screen, fireEvent, waitFor } from '@testing-library/react'; import { createLocalStorage } from 'utils/createLocalStorage'; import { Route, Routes } from 'react-router-dom'; import { listItemButtonClasses as classes } from '@mui/material/ListItemButton'; +import { + type LastViewedFlag, + useLastViewedFlags, +} from '../../../../hooks/useLastViewedFlags'; +import { type FC, useEffect } from 'react'; +import { useLastViewedProject } from '../../../../hooks/useLastViewedProject'; beforeEach(() => { window.localStorage.clear(); @@ -69,3 +75,32 @@ test('select active item', async () => { expect(links[1]).toHaveClass(classes.selected); }); + +const SetupComponent: FC<{ project: string; flags: LastViewedFlag[] }> = ({ + project, + flags, +}) => { + const { setLastViewed: setProject } = useLastViewedProject(); + const { setLastViewed: setFlag } = useLastViewedFlags(); + + useEffect(() => { + setProject(project); + flags.forEach((flag) => { + setFlag(flag); + }); + }, []); + + return ; +}; + +test('print recent projects and flags', async () => { + render( + , + ); + + await screen.findByText('projectA'); + await screen.findByText('featureA'); +}); diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx index 2f042ddcd1..e35eaad4bd 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx @@ -7,12 +7,14 @@ import { useExpanded } from './useExpanded'; import { OtherLinksList, PrimaryNavigationList, + RecentFlagsNavigation, RecentProjectsNavigation, SecondaryNavigation, SecondaryNavigationList, } from './NavigationList'; import { useInitialPathname } from './useInitialPathname'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; +import { useLastViewedFlags } from 'hooks/useLastViewedFlags'; export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({ onClick, @@ -56,8 +58,11 @@ export const NavigationSidebar = () => { const [activeItem, setActiveItem] = useState(initialPathname); - const { lastViewed } = useLastViewedProject(); - const showRecentProject = mode === 'full' && lastViewed; + const { lastViewed: lastViewedProject } = useLastViewedProject(); + const showRecentProject = mode === 'full' && lastViewedProject; + + const { lastViewed: lastViewedFlags } = useLastViewedFlags(); + const showRecentFlags = mode === 'full' && lastViewedFlags.length > 0; return ( @@ -111,7 +116,15 @@ export const NavigationSidebar = () => { {showRecentProject && ( setActiveItem('/projects')} + /> + )} + + {showRecentFlags && ( + setActiveItem('/projects')} /> )} diff --git a/frontend/src/hooks/useCustomEvent.ts b/frontend/src/hooks/useCustomEvent.ts index db6b199682..4dfa164d4b 100644 --- a/frontend/src/hooks/useCustomEvent.ts +++ b/frontend/src/hooks/useCustomEvent.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; /** * A hook that provides methods to emit and listen to custom DOM events. @@ -8,10 +8,10 @@ export const useCustomEvent = ( eventName: string, handler: (event: CustomEvent) => void, ) => { - const emitEvent = () => { + const emitEvent = useCallback(() => { const event = new CustomEvent(eventName); document.dispatchEvent(event); - }; + }, [eventName]); useEffect(() => { const eventListener = (event: Event) => handler(event as CustomEvent); diff --git a/frontend/src/hooks/useLastViewedFlags.test.tsx b/frontend/src/hooks/useLastViewedFlags.test.tsx new file mode 100644 index 0000000000..f9c07e1687 --- /dev/null +++ b/frontend/src/hooks/useLastViewedFlags.test.tsx @@ -0,0 +1,82 @@ +import type React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useLastViewedFlags } from './useLastViewedFlags'; + +const TestComponent: React.FC<{ + testId: string; + featureId: string; + projectId: string; +}> = ({ testId, featureId, projectId }) => { + const { lastViewed, setLastViewed } = useLastViewedFlags(); + + const handleUpdate = () => { + setLastViewed({ featureId, projectId }); + }; + + return ( +
+
+ {lastViewed.map((flag, index) => ( +
{`${flag.featureId} - ${flag.projectId}`}
+ ))} +
+ +
+ ); +}; + +describe('Last three unique flags are persisted and duplicates are skipped', () => { + beforeEach(() => { + localStorage.clear(); + render( + <> + + + + + , + ); + }); + + it('only persists the last three unique flags across components and skips duplicates', async () => { + fireEvent.click(screen.getByTestId('update-component1')); + fireEvent.click(screen.getByTestId('update-component2')); + fireEvent.click(screen.getByTestId('update-component1')); // duplicate + fireEvent.click(screen.getByTestId('update-component3')); + fireEvent.click(screen.getByTestId('update-component4')); + + expect(await screen.findAllByText('Feature2 - Project2')).toHaveLength( + 4, + ); + expect(await screen.findAllByText('Feature3 - Project3')).toHaveLength( + 4, + ); + expect(await screen.findAllByText('Feature4 - Project4')).toHaveLength( + 4, + ); + }); +}); diff --git a/frontend/src/hooks/useLastViewedFlags.ts b/frontend/src/hooks/useLastViewedFlags.ts new file mode 100644 index 0000000000..bd13535dc1 --- /dev/null +++ b/frontend/src/hooks/useLastViewedFlags.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; +import { basePath } from 'utils/formatPath'; +import { useCustomEvent } from './useCustomEvent'; + +const MAX_ITEMS = 3; + +export type LastViewedFlag = { featureId: string; projectId: string }; + +const removeIncorrect = (flags?: any[]): LastViewedFlag[] => { + if (!Array.isArray(flags)) return []; + return flags.filter((flag) => flag.featureId && flag.projectId); +}; + +const localStorageItems = (key: string) => { + return removeIncorrect(getLocalStorageItem(key) || []); +}; + +export const useLastViewedFlags = () => { + const key = `${basePath}:unleash-lastViewedFlags`; + const [lastViewed, setLastViewed] = useState(() => + localStorageItems(key), + ); + + const { emitEvent } = useCustomEvent( + 'lastViewedFlagsUpdated', + useCallback(() => { + setLastViewed(localStorageItems(key)); + }, [key]), + ); + + useEffect(() => { + if (lastViewed) { + setLocalStorageItem(key, lastViewed); + emitEvent(); + } + }, [JSON.stringify(lastViewed), key, emitEvent]); + + const setCappedLastViewed = useCallback( + (flag: { featureId: string; projectId: string }) => { + if (!flag.featureId || !flag.projectId) return; + if (lastViewed.find((item) => item.featureId === flag.featureId)) + return; + const updatedLastViewed = removeIncorrect([...lastViewed, flag]); + setLastViewed( + updatedLastViewed.length > MAX_ITEMS + ? updatedLastViewed.slice(-MAX_ITEMS) + : updatedLastViewed, + ); + }, + [JSON.stringify(lastViewed)], + ); + + return { lastViewed, setLastViewed: setCappedLastViewed }; +}; diff --git a/frontend/src/hooks/useLastViewedProject.ts b/frontend/src/hooks/useLastViewedProject.ts index 9775b8f821..14bb00a360 100644 --- a/frontend/src/hooks/useLastViewedProject.ts +++ b/frontend/src/hooks/useLastViewedProject.ts @@ -19,7 +19,7 @@ export const useLastViewedProject = () => { setLocalStorageItem(key, lastViewed); emitEvent(); } - }, [lastViewed, key]); + }, [lastViewed, key, emitEvent]); return { lastViewed,