mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +01:00
feat: Recent flags (#7211)
This commit is contained in:
parent
88c7e9aa0e
commit
95f5f7a20b
@ -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 (
|
||||
<StyledContainer>
|
||||
|
@ -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 (
|
||||
<List>
|
||||
{flags.map((flag) => (
|
||||
<DynamicListItem
|
||||
href={`/projects/${flag.projectId}/features/${flag.featureId}`}
|
||||
text={flag.featureId}
|
||||
onClick={onClick}
|
||||
selected={false}
|
||||
key={flag.featureId}
|
||||
>
|
||||
<FlagIcon />
|
||||
</DynamicListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const PrimaryNavigationList: FC<{
|
||||
mode: NavigationMode;
|
||||
onClick: (activeItem: string) => void;
|
||||
@ -226,3 +251,22 @@ export const RecentProjectsNavigation: FC<{
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const RecentFlagsNavigation: FC<{
|
||||
mode: NavigationMode;
|
||||
flags: { featureId: string; projectId: string }[];
|
||||
onClick: () => void;
|
||||
}> = ({ mode, onClick, flags }) => {
|
||||
return (
|
||||
<Box>
|
||||
{mode === 'full' && (
|
||||
<Typography
|
||||
sx={{ fontWeight: 'bold', fontSize: 'small', mb: 1, ml: 2 }}
|
||||
>
|
||||
Recent flags
|
||||
</Typography>
|
||||
)}
|
||||
<RecentFlagsList flags={flags} mode={mode} onClick={onClick} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -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 <NavigationSidebar />;
|
||||
};
|
||||
|
||||
test('print recent projects and flags', async () => {
|
||||
render(
|
||||
<SetupComponent
|
||||
project={'projectA'}
|
||||
flags={[{ featureId: 'featureA', projectId: 'projectB' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('projectA');
|
||||
await screen.findByText('featureA');
|
||||
});
|
||||
|
@ -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 (
|
||||
<StretchContainer>
|
||||
@ -111,7 +116,15 @@ export const NavigationSidebar = () => {
|
||||
{showRecentProject && (
|
||||
<RecentProjectsNavigation
|
||||
mode={mode}
|
||||
projectId={lastViewed}
|
||||
projectId={lastViewedProject}
|
||||
onClick={() => setActiveItem('/projects')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRecentFlags && (
|
||||
<RecentFlagsNavigation
|
||||
mode={mode}
|
||||
flags={lastViewedFlags}
|
||||
onClick={() => setActiveItem('/projects')}
|
||||
/>
|
||||
)}
|
||||
|
@ -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);
|
||||
|
82
frontend/src/hooks/useLastViewedFlags.test.tsx
Normal file
82
frontend/src/hooks/useLastViewedFlags.test.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<div data-testid={testId}>
|
||||
{lastViewed.map((flag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
>{`${flag.featureId} - ${flag.projectId}`}</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleUpdate}
|
||||
data-testid={`update-${testId}`}
|
||||
>
|
||||
Add {featureId} {projectId}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Last three unique flags are persisted and duplicates are skipped', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
render(
|
||||
<>
|
||||
<TestComponent
|
||||
testId='component1'
|
||||
featureId='Feature1'
|
||||
projectId='Project1'
|
||||
/>
|
||||
<TestComponent
|
||||
testId='component2'
|
||||
featureId='Feature2'
|
||||
projectId='Project2'
|
||||
/>
|
||||
<TestComponent
|
||||
testId='component3'
|
||||
featureId='Feature3'
|
||||
projectId='Project3'
|
||||
/>
|
||||
<TestComponent
|
||||
testId='component4'
|
||||
featureId='Feature4'
|
||||
projectId='Project4'
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
55
frontend/src/hooks/useLastViewedFlags.ts
Normal file
55
frontend/src/hooks/useLastViewedFlags.ts
Normal file
@ -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<LastViewedFlag[]>(() =>
|
||||
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 };
|
||||
};
|
@ -19,7 +19,7 @@ export const useLastViewedProject = () => {
|
||||
setLocalStorageItem(key, lastViewed);
|
||||
emitEvent();
|
||||
}
|
||||
}, [lastViewed, key]);
|
||||
}, [lastViewed, key, emitEvent]);
|
||||
|
||||
return {
|
||||
lastViewed,
|
||||
|
Loading…
Reference in New Issue
Block a user