1
0
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:
Mateusz Kwasniewski 2024-05-29 16:01:52 +02:00 committed by GitHub
parent 88c7e9aa0e
commit 95f5f7a20b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 246 additions and 9 deletions

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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');
});

View File

@ -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')}
/>
)}

View File

@ -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);

View 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,
);
});
});

View 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 };
};

View File

@ -19,7 +19,7 @@ export const useLastViewedProject = () => {
setLocalStorageItem(key, lastViewed);
emitEvent();
}
}, [lastViewed, key]);
}, [lastViewed, key, emitEvent]);
return {
lastViewed,