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 FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
|
||||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
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 { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
|
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
|
||||||
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
|
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
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 }) => ({
|
const StyledContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -37,6 +41,10 @@ const FeatureOverview = () => {
|
|||||||
useHiddenEnvironments();
|
useHiddenEnvironments();
|
||||||
const onSidebarClose = () => navigate(featurePath);
|
const onSidebarClose = () => navigate(featurePath);
|
||||||
usePageTitle(featureId);
|
usePageTitle(featureId);
|
||||||
|
const { setLastViewed } = useLastViewedFlags();
|
||||||
|
useEffect(() => {
|
||||||
|
setLastViewed({ featureId, projectId });
|
||||||
|
}, [featureId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
|
@ -18,6 +18,7 @@ import Accordion from '@mui/material/Accordion';
|
|||||||
import AccordionDetails from '@mui/material/AccordionDetails';
|
import AccordionDetails from '@mui/material/AccordionDetails';
|
||||||
import AccordionSummary from '@mui/material/AccordionSummary';
|
import AccordionSummary from '@mui/material/AccordionSummary';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import FlagIcon from '@mui/icons-material/OutlinedFlag';
|
||||||
|
|
||||||
const StyledBadgeContainer = styled('div')(({ theme }) => ({
|
const StyledBadgeContainer = styled('div')(({ theme }) => ({
|
||||||
paddingLeft: theme.spacing(2),
|
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<{
|
export const PrimaryNavigationList: FC<{
|
||||||
mode: NavigationMode;
|
mode: NavigationMode;
|
||||||
onClick: (activeItem: string) => void;
|
onClick: (activeItem: string) => void;
|
||||||
@ -226,3 +251,22 @@ export const RecentProjectsNavigation: FC<{
|
|||||||
</Box>
|
</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 { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import { listItemButtonClasses as classes } from '@mui/material/ListItemButton';
|
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(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@ -69,3 +75,32 @@ test('select active item', async () => {
|
|||||||
|
|
||||||
expect(links[1]).toHaveClass(classes.selected);
|
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 {
|
import {
|
||||||
OtherLinksList,
|
OtherLinksList,
|
||||||
PrimaryNavigationList,
|
PrimaryNavigationList,
|
||||||
|
RecentFlagsNavigation,
|
||||||
RecentProjectsNavigation,
|
RecentProjectsNavigation,
|
||||||
SecondaryNavigation,
|
SecondaryNavigation,
|
||||||
SecondaryNavigationList,
|
SecondaryNavigationList,
|
||||||
} from './NavigationList';
|
} from './NavigationList';
|
||||||
import { useInitialPathname } from './useInitialPathname';
|
import { useInitialPathname } from './useInitialPathname';
|
||||||
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||||
|
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
|
||||||
|
|
||||||
export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({
|
export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({
|
||||||
onClick,
|
onClick,
|
||||||
@ -56,8 +58,11 @@ export const NavigationSidebar = () => {
|
|||||||
|
|
||||||
const [activeItem, setActiveItem] = useState(initialPathname);
|
const [activeItem, setActiveItem] = useState(initialPathname);
|
||||||
|
|
||||||
const { lastViewed } = useLastViewedProject();
|
const { lastViewed: lastViewedProject } = useLastViewedProject();
|
||||||
const showRecentProject = mode === 'full' && lastViewed;
|
const showRecentProject = mode === 'full' && lastViewedProject;
|
||||||
|
|
||||||
|
const { lastViewed: lastViewedFlags } = useLastViewedFlags();
|
||||||
|
const showRecentFlags = mode === 'full' && lastViewedFlags.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StretchContainer>
|
<StretchContainer>
|
||||||
@ -111,7 +116,15 @@ export const NavigationSidebar = () => {
|
|||||||
{showRecentProject && (
|
{showRecentProject && (
|
||||||
<RecentProjectsNavigation
|
<RecentProjectsNavigation
|
||||||
mode={mode}
|
mode={mode}
|
||||||
projectId={lastViewed}
|
projectId={lastViewedProject}
|
||||||
|
onClick={() => setActiveItem('/projects')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRecentFlags && (
|
||||||
|
<RecentFlagsNavigation
|
||||||
|
mode={mode}
|
||||||
|
flags={lastViewedFlags}
|
||||||
onClick={() => setActiveItem('/projects')}
|
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.
|
* A hook that provides methods to emit and listen to custom DOM events.
|
||||||
@ -8,10 +8,10 @@ export const useCustomEvent = (
|
|||||||
eventName: string,
|
eventName: string,
|
||||||
handler: (event: CustomEvent) => void,
|
handler: (event: CustomEvent) => void,
|
||||||
) => {
|
) => {
|
||||||
const emitEvent = () => {
|
const emitEvent = useCallback(() => {
|
||||||
const event = new CustomEvent(eventName);
|
const event = new CustomEvent(eventName);
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
};
|
}, [eventName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const eventListener = (event: Event) => handler(event as CustomEvent);
|
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);
|
setLocalStorageItem(key, lastViewed);
|
||||||
emitEvent();
|
emitEvent();
|
||||||
}
|
}
|
||||||
}, [lastViewed, key]);
|
}, [lastViewed, key, emitEvent]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastViewed,
|
lastViewed,
|
||||||
|
Loading…
Reference in New Issue
Block a user