1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-09 01:17:06 +02:00

feat: make panels collapsible (#8395)

This PR makes the projects and flags panels collapsible. The panels are
expanded by default and can be collapsed by clicking on the panel
header. The state of the panels is saved in localstorage.

As part of this, it also:
- moves the flag exposure metrics next to the metric selectors
- fixes the alignment of the "no exposure" line


![image](https://github.com/user-attachments/assets/b41ca808-f5f0-4e17-8bb1-b1388256354d)

Line alignment:
before:

![image](https://github.com/user-attachments/assets/119320d6-d39d-4c34-815a-8a25c6856ad6)

after:

![image](https://github.com/user-attachments/assets/f5b0fe51-1cda-49f9-8b22-e03988429799)
This commit is contained in:
Thomas Heartman 2024-10-09 14:25:58 +02:00 committed by GitHub
parent ca831f79e5
commit 23b0401381
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 231 additions and 132 deletions

View File

@ -74,6 +74,11 @@ const TooltipContainer: FC<{
); );
}; };
const LineBox = styled(Box)({
display: 'grid',
placeItems: 'center',
});
export const FeatureEnvironmentSeen = ({ export const FeatureEnvironmentSeen = ({
featureLastSeen, featureLastSeen,
environments, environments,
@ -91,9 +96,9 @@ export const FeatureEnvironmentSeen = ({
tooltip='No usage reported from connected applications' tooltip='No usage reported from connected applications'
> >
<Box data-loading> <Box data-loading>
<Box> <LineBox>
<UsageLine /> <UsageLine />
</Box> </LineBox>
</Box> </Box>
</TooltipContainer> </TooltipContainer>
); );

View File

@ -12,7 +12,8 @@ export const FlagExposure: FC<{
project: string; project: string;
flagName: string; flagName: string;
onArchive: () => void; onArchive: () => void;
}> = ({ project, flagName, onArchive }) => { className?: string;
}> = ({ project, flagName, onArchive, className }) => {
const { feature, refetchFeature } = useFeature(project, flagName); const { feature, refetchFeature } = useFeature(project, flagName);
const lastSeenEnvironments: ILastSeenEnvironments[] = const lastSeenEnvironments: ILastSeenEnvironments[] =
feature.environments?.map((env) => ({ feature.environments?.map((env) => ({
@ -27,7 +28,7 @@ export const FlagExposure: FC<{
useState(false); useState(false);
return ( return (
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }} className={className}>
<FeatureEnvironmentSeen <FeatureEnvironmentSeen
sx={{ pt: 0, pb: 0 }} sx={{ pt: 0, pb: 0 }}
featureLastSeen={feature.lastSeenAt} featureLastSeen={feature.lastSeenAt}

View File

@ -108,12 +108,6 @@ export const ContentGridNoProjects: React.FC<Props> = ({ owners, admins }) => {
return ( return (
<ContentGridContainer> <ContentGridContainer>
<ProjectGrid> <ProjectGrid>
<GridItem gridArea='header'>
<Typography variant='h3'>My projects</Typography>
</GridItem>
<GridItem gridArea='onboarding'>
<Typography>Potential next steps</Typography>
</GridItem>
<GridItem gridArea='projects'> <GridItem gridArea='projects'>
<GridContent> <GridContent>
<Typography> <Typography>

View File

@ -23,6 +23,7 @@ import {
createPlaceholderBarChartOptions, createPlaceholderBarChartOptions,
} from './createChartOptions'; } from './createChartOptions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { FlagExposure } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FlagExposure';
const defaultYes = [0, 14, 28, 21, 33, 31, 31, 22, 26, 37, 31, 14, 21, 14, 0]; const defaultYes = [0, 14, 28, 21, 33, 31, 31, 22, 26, 37, 31, 14, 21, 14, 0];
@ -140,7 +141,7 @@ const EnvironmentSelect: FC<{
); );
}; };
const MetricsSelectors = styled(Box)(({ theme }) => ({ const ExposureAndSelectors = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
gap: theme.spacing(2), gap: theme.spacing(2),
@ -154,9 +155,14 @@ const ChartContainer = styled('div')(({ theme }) => ({
alignItems: 'center', alignItems: 'center',
})); }));
const StyledExposure = styled(FlagExposure)({
alignItems: 'center',
});
export const FlagMetricsChart: FC<{ export const FlagMetricsChart: FC<{
flag: { name: string; project: string }; flag: { name: string; project: string };
}> = ({ flag }) => { onArchive: () => void;
}> = ({ flag, onArchive }) => {
const [hoursBack, setHoursBack] = useState(48); const [hoursBack, setHoursBack] = useState(48);
const { environment, setEnvironment, activeEnvironments } = const { environment, setEnvironment, activeEnvironments } =
@ -168,7 +174,12 @@ export const FlagMetricsChart: FC<{
return ( return (
<ChartContainer> <ChartContainer>
<MetricsSelectors> <ExposureAndSelectors>
<StyledExposure
project={flag.project}
flagName={flag.name}
onArchive={onArchive}
/>
{environment ? ( {environment ? (
<EnvironmentSelect <EnvironmentSelect
environment={environment} environment={environment}
@ -180,7 +191,7 @@ export const FlagMetricsChart: FC<{
hoursBack={hoursBack} hoursBack={hoursBack}
setHoursBack={setHoursBack} setHoursBack={setHoursBack}
/> />
</MetricsSelectors> </ExposureAndSelectors>
{noData ? ( {noData ? (
<PlaceholderFlagMetricsChart /> <PlaceholderFlagMetricsChart />

View File

@ -10,7 +10,6 @@ const ContentGrid = styled('article')(({ theme }) => {
backgroundColor: theme.palette.divider, backgroundColor: theme.palette.divider,
borderRadius: `${theme.shape.borderRadiusLarge}px`, borderRadius: `${theme.shape.borderRadiusLarge}px`,
overflow: 'hidden', overflow: 'hidden',
border: `0.5px solid ${theme.palette.divider}`,
gap: `1px`, gap: `1px`,
display: 'flex', display: 'flex',
flexFlow: 'column nowrap', flexFlow: 'column nowrap',
@ -41,7 +40,6 @@ export const ProjectGrid = styled(ContentGrid)(
gridTemplateColumns: '1fr 1fr 1fr', gridTemplateColumns: '1fr 1fr 1fr',
display: 'grid', display: 'grid',
gridTemplateAreas: ` gridTemplateAreas: `
"header header header"
"projects box1 box2" "projects box1 box2"
". owners owners" ". owners owners"
`, `,
@ -53,7 +51,6 @@ export const FlagGrid = styled(ContentGrid)(
gridTemplateColumns: '1fr 1fr 1fr', gridTemplateColumns: '1fr 1fr 1fr',
display: 'grid', display: 'grid',
gridTemplateAreas: ` gridTemplateAreas: `
"title lifecycle lifecycle"
"flags chart chart" "flags chart chart"
`, `,
}), }),

View File

@ -189,9 +189,6 @@ export const MyProjects = forwardRef<
return ( return (
<ContentGridContainer ref={ref}> <ContentGridContainer ref={ref}>
<ProjectGrid> <ProjectGrid>
<GridItem gridArea='header'>
<Typography variant='h3'>My projects</Typography>
</GridItem>
<SpacedGridItem gridArea='projects'> <SpacedGridItem gridArea='projects'>
<List <List
disablePadding={true} disablePadding={true}

View File

@ -114,7 +114,9 @@ const setupNewProject = () => {
}); });
}; };
// @ts-ignore // @ts-expect-error The return type here isn't correct, but it's not
// an issue for the tests. We just need to override it because it's
// not implemented in jsdom.
HTMLCanvasElement.prototype.getContext = () => {}; HTMLCanvasElement.prototype.getContext = () => {};
//scrollIntoView is not implemented in jsdom //scrollIntoView is not implemented in jsdom
@ -134,7 +136,7 @@ test('Render personal dashboard for a long running project', async () => {
await screen.findByText('projectName'); await screen.findByText('projectName');
await screen.findByText('10'); // members await screen.findByText('10'); // members
await screen.findByText('100'); // features await screen.findByText('100'); // features
await screen.findByText('80%'); // health await screen.findAllByText('80%'); // health
await screen.findByText('Project health'); await screen.findByText('Project health');
await screen.findByText('70%'); // avg health past window await screen.findByText('70%'); // avg health past window

View File

@ -1,5 +1,8 @@
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import { import {
Accordion,
AccordionDetails,
AccordionSummary,
Button, Button,
IconButton, IconButton,
Link, Link,
@ -19,7 +22,6 @@ import type {
PersonalDashboardSchemaFlagsItem, PersonalDashboardSchemaFlagsItem,
PersonalDashboardSchemaProjectsItem, PersonalDashboardSchemaProjectsItem,
} from '../../openapi'; } from '../../openapi';
import { FlagExposure } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FlagExposure';
import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails'; import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails';
import useLoading from '../../hooks/useLoading'; import useLoading from '../../hooks/useLoading';
import { MyProjects } from './MyProjects'; import { MyProjects } from './MyProjects';
@ -28,16 +30,10 @@ import {
FlagGrid, FlagGrid,
ListItemBox, ListItemBox,
listItemStyle, listItemStyle,
GridItem,
SpacedGridItem, SpacedGridItem,
} from './Grid'; } from './Grid';
import { ContentGridNoProjects } from './ContentGridNoProjects'; import { ContentGridNoProjects } from './ContentGridNoProjects';
import ExpandMore from '@mui/icons-material/ExpandMore';
const ScreenExplanation = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(4),
display: 'flex',
alignItems: 'center',
}));
export const StyledCardTitle = styled('div')<{ lines?: number }>( export const StyledCardTitle = styled('div')<{ lines?: number }>(
({ theme, lines = 2 }) => ({ ({ theme, lines = 2 }) => ({
@ -102,6 +98,7 @@ const FlagListItem: FC<{
); );
}; };
// todo: move into own file
const useDashboardState = ( const useDashboardState = (
projects: PersonalDashboardSchemaProjectsItem[], projects: PersonalDashboardSchemaProjectsItem[],
flags: PersonalDashboardSchemaFlagsItem[], flags: PersonalDashboardSchemaFlagsItem[],
@ -109,11 +106,15 @@ const useDashboardState = (
type State = { type State = {
activeProject: string | undefined; activeProject: string | undefined;
activeFlag: PersonalDashboardSchemaFlagsItem | undefined; activeFlag: PersonalDashboardSchemaFlagsItem | undefined;
expandProjects: boolean;
expandFlags: boolean;
}; };
const defaultState = { const defaultState: State = {
activeProject: undefined, activeProject: undefined,
activeFlag: undefined, activeFlag: undefined,
expandProjects: true,
expandFlags: true,
}; };
const [state, setState] = useLocalStorageState<State>( const [state, setState] = useLocalStorageState<State>(
@ -121,11 +122,20 @@ const useDashboardState = (
defaultState, defaultState,
); );
const updateState = (newState: Partial<State>) =>
setState({ ...defaultState, ...state, ...newState });
useEffect(() => { useEffect(() => {
const updates: Partial<State> = {};
const setDefaultFlag = const setDefaultFlag =
flags.length && flags.length &&
(!state.activeFlag || (!state.activeFlag ||
!flags.some((flag) => flag.name === state.activeFlag?.name)); !flags.some((flag) => flag.name === state.activeFlag?.name));
if (setDefaultFlag) {
updates.activeFlag = flags[0];
}
const setDefaultProject = const setDefaultProject =
projects.length && projects.length &&
(!state.activeProject || (!state.activeProject ||
@ -133,37 +143,48 @@ const useDashboardState = (
(project) => project.id === state.activeProject, (project) => project.id === state.activeProject,
)); ));
if (setDefaultFlag || setDefaultProject) { if (setDefaultProject) {
setState({ updates.activeProject = projects[0].id;
activeFlag: setDefaultFlag ? flags[0] : state.activeFlag,
activeProject: setDefaultProject
? projects[0].id
: state.activeProject,
});
} }
});
if (Object.keys(updates).length) {
updateState(updates);
}
}, [
JSON.stringify(projects, null, 2),
JSON.stringify(flags, null, 2),
JSON.stringify(state, null, 2),
]);
const { activeFlag, activeProject } = state; const { activeFlag, activeProject } = state;
const setActiveFlag = (flag: PersonalDashboardSchemaFlagsItem) => { const setActiveFlag = (flag: PersonalDashboardSchemaFlagsItem) => {
setState({ updateState({
...state,
activeFlag: flag, activeFlag: flag,
}); });
}; };
const setActiveProject = (projectId: string) => { const setActiveProject = (projectId: string) => {
setState({ updateState({
...state,
activeProject: projectId, activeProject: projectId,
}); });
}; };
const toggleSectionState = (section: 'flags' | 'projects') => {
const property = section === 'flags' ? 'expandFlags' : 'expandProjects';
updateState({
[property]: !(state[property] ?? true),
});
};
return { return {
activeFlag, activeFlag,
setActiveFlag, setActiveFlag,
activeProject, activeProject,
setActiveProject, setActiveProject,
expandFlags: state.expandFlags ?? true,
expandProjects: state.expandProjects ?? true,
toggleSectionState,
}; };
}; };
@ -173,13 +194,57 @@ const WelcomeSection = styled('div')(({ theme }) => ({
gap: theme.spacing(1), gap: theme.spacing(1),
flexFlow: 'row wrap', flexFlow: 'row wrap',
alignItems: 'baseline', alignItems: 'baseline',
marginBottom: theme.spacing(2),
})); }));
const ViewKeyConceptsButton = styled(Button)(({ theme }) => ({ const ViewKeyConceptsButton = styled(Button)({
fontWeight: 'normal', fontWeight: 'normal',
padding: 0, padding: 0,
margin: 0, margin: 0,
});
const SectionAccordion = styled(Accordion)(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
backgroundColor: theme.palette.background.paper,
boxShadow: 'none',
'& .expanded': {
'&:before': {
opacity: '0 !important',
},
},
// add a top border to the region when the accordion is collapsed.
// This retains the border between the summary and the region
// during the collapsing animation
"[aria-expanded='false']+.MuiCollapse-root .MuiAccordion-region": {
borderTop: `1px solid ${theme.palette.divider}`,
},
overflow: 'hidden',
}));
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
border: 'none',
padding: theme.spacing(2, 4),
margin: 0,
// increase specificity to override the default margin
'&>.MuiAccordionSummary-content.MuiAccordionSummary-content': {
margin: '0',
},
"&[aria-expanded='true']": {
// only add the border when it's open
borderBottom: `1px solid ${theme.palette.divider}`,
},
}));
const StyledAccordionDetails = styled(AccordionDetails)({
padding: 0,
});
const MainContent = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
})); }));
export const PersonalDashboard = () => { export const PersonalDashboard = () => {
@ -187,32 +252,30 @@ export const PersonalDashboard = () => {
const name = user?.name; const name = user?.name;
const { const { personalDashboard, refetch: refetchDashboard } =
personalDashboard, usePersonalDashboard();
refetch: refetchDashboard,
loading: personalDashboardLoading,
} = usePersonalDashboard();
const projects = personalDashboard?.projects || []; const projects = personalDashboard?.projects || [];
const { activeProject, setActiveProject, activeFlag, setActiveFlag } = const {
useDashboardState(projects, personalDashboard?.flags ?? []); activeProject,
setActiveProject,
activeFlag,
setActiveFlag,
toggleSectionState,
expandFlags,
expandProjects,
} = useDashboardState(projects, personalDashboard?.flags ?? []);
const [welcomeDialog, setWelcomeDialog] = useLocalStorageState< const [welcomeDialog, setWelcomeDialog] = useLocalStorageState<
'open' | 'closed' 'open' | 'closed'
>('welcome-dialog:v1', 'open'); >('welcome-dialog:v1', 'open');
const { const { personalDashboardProjectDetails, error: detailsError } =
personalDashboardProjectDetails, usePersonalDashboardProjectDetails(activeProject);
loading: loadingDetails,
error: detailsError,
} = usePersonalDashboardProjectDetails(activeProject);
const activeProjectStage = const activeProjectStage =
personalDashboardProjectDetails?.onboardingStatus.status ?? 'loading'; personalDashboardProjectDetails?.onboardingStatus.status ?? 'loading';
const setupIncomplete =
activeProjectStage === 'onboarding-started' ||
activeProjectStage === 'first-flag-created';
const noProjects = projects.length === 0; const noProjects = projects.length === 0;
@ -221,7 +284,7 @@ export const PersonalDashboard = () => {
); );
return ( return (
<div> <MainContent>
<WelcomeSection> <WelcomeSection>
<Typography component='h2' variant='h2'> <Typography component='h2' variant='h2'>
Welcome {name} Welcome {name}
@ -239,84 +302,113 @@ export const PersonalDashboard = () => {
</ViewKeyConceptsButton> </ViewKeyConceptsButton>
</WelcomeSection> </WelcomeSection>
{noProjects && personalDashboard ? ( <SectionAccordion
<ContentGridNoProjects disableGutters
owners={personalDashboard.projectOwners} expanded={expandProjects ?? true}
admins={personalDashboard.admins} onChange={() => toggleSectionState('projects')}
/> >
) : ( <StyledAccordionSummary
<MyProjects expandIcon={
admins={personalDashboard?.admins ?? []} <ExpandMore titleAccess='Toggle projects section' />
ref={projectStageRef}
projects={projects}
activeProject={activeProject || ''}
setActiveProject={setActiveProject}
personalDashboardProjectDetails={
personalDashboardProjectDetails
} }
/> id='projects-panel-header'
)} aria-controls='projects-panel-content'
>
<Typography variant='body1' component='h3'>
My projects
</Typography>
</StyledAccordionSummary>
<StyledAccordionDetails>
{noProjects && personalDashboard ? (
<ContentGridNoProjects
owners={personalDashboard.projectOwners}
admins={personalDashboard.admins}
/>
) : (
<MyProjects
admins={personalDashboard?.admins ?? []}
ref={projectStageRef}
projects={projects}
activeProject={activeProject || ''}
setActiveProject={setActiveProject}
personalDashboardProjectDetails={
personalDashboardProjectDetails
}
/>
)}
</StyledAccordionDetails>
</SectionAccordion>
<ContentGridContainer> <SectionAccordion
<FlagGrid sx={{ mt: 2 }}> expanded={expandFlags ?? true}
<GridItem onChange={() => toggleSectionState('flags')}
gridArea='title' >
sx={{ display: 'flex', alignItems: 'center' }} <StyledAccordionSummary
> expandIcon={
<Typography variant='h3'>My feature flags</Typography> <ExpandMore titleAccess='Toggle flags section' />
</GridItem> }
<GridItem id='flags-panel-header'
gridArea='lifecycle' aria-controls='flags-panel-content'
sx={{ display: 'flex', justifyContent: 'flex-end' }} >
> <Typography variant='body1' component='h3'>
{activeFlag ? ( My feature flags
<FlagExposure </Typography>
project={activeFlag.project} </StyledAccordionSummary>
flagName={activeFlag.name} <StyledAccordionDetails>
onArchive={refetchDashboard} <ContentGridContainer>
/> <FlagGrid>
) : null} <SpacedGridItem gridArea='flags'>
</GridItem> {personalDashboard &&
<SpacedGridItem gridArea='flags'> personalDashboard.flags.length > 0 ? (
{personalDashboard && <List
personalDashboard.flags.length > 0 ? ( disablePadding={true}
<List sx={{
disablePadding={true} maxHeight: '400px',
sx={{ maxHeight: '400px', overflow: 'auto' }} overflow: 'auto',
> }}
{personalDashboard.flags.map((flag) => ( >
<FlagListItem {personalDashboard.flags.map((flag) => (
key={flag.name} <FlagListItem
flag={flag} key={flag.name}
selected={ flag={flag}
flag.name === activeFlag?.name selected={
} flag.name ===
onClick={() => setActiveFlag(flag)} activeFlag?.name
}
onClick={() =>
setActiveFlag(flag)
}
/>
))}
</List>
) : (
<Typography>
You have not created or favorited any
feature flags. Once you do, they will
show up here.
</Typography>
)}
</SpacedGridItem>
<SpacedGridItem gridArea='chart'>
{activeFlag ? (
<FlagMetricsChart
flag={activeFlag}
onArchive={refetchDashboard}
/> />
))} ) : (
</List> <PlaceholderFlagMetricsChart />
) : ( )}
<Typography> </SpacedGridItem>
You have not created or favorited any feature </FlagGrid>
flags. Once you do, they will show up here. </ContentGridContainer>
</Typography> </StyledAccordionDetails>
)} </SectionAccordion>
</SpacedGridItem>
<SpacedGridItem gridArea='chart'>
{activeFlag ? (
<FlagMetricsChart flag={activeFlag} />
) : (
<PlaceholderFlagMetricsChart />
)}
</SpacedGridItem>
</FlagGrid>
</ContentGridContainer>
<WelcomeDialog <WelcomeDialog
open={welcomeDialog === 'open'} open={welcomeDialog === 'open'}
onClose={() => setWelcomeDialog('closed')} onClose={() => setWelcomeDialog('closed')}
/> />
</div> </MainContent>
); );
}; };