1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +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 = ({
featureLastSeen,
environments,
@ -91,9 +96,9 @@ export const FeatureEnvironmentSeen = ({
tooltip='No usage reported from connected applications'
>
<Box data-loading>
<Box>
<LineBox>
<UsageLine />
</Box>
</LineBox>
</Box>
</TooltipContainer>
);

View File

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

View File

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

View File

@ -23,6 +23,7 @@ import {
createPlaceholderBarChartOptions,
} from './createChartOptions';
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];
@ -140,7 +141,7 @@ const EnvironmentSelect: FC<{
);
};
const MetricsSelectors = styled(Box)(({ theme }) => ({
const ExposureAndSelectors = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(2),
@ -154,9 +155,14 @@ const ChartContainer = styled('div')(({ theme }) => ({
alignItems: 'center',
}));
const StyledExposure = styled(FlagExposure)({
alignItems: 'center',
});
export const FlagMetricsChart: FC<{
flag: { name: string; project: string };
}> = ({ flag }) => {
onArchive: () => void;
}> = ({ flag, onArchive }) => {
const [hoursBack, setHoursBack] = useState(48);
const { environment, setEnvironment, activeEnvironments } =
@ -168,7 +174,12 @@ export const FlagMetricsChart: FC<{
return (
<ChartContainer>
<MetricsSelectors>
<ExposureAndSelectors>
<StyledExposure
project={flag.project}
flagName={flag.name}
onArchive={onArchive}
/>
{environment ? (
<EnvironmentSelect
environment={environment}
@ -180,7 +191,7 @@ export const FlagMetricsChart: FC<{
hoursBack={hoursBack}
setHoursBack={setHoursBack}
/>
</MetricsSelectors>
</ExposureAndSelectors>
{noData ? (
<PlaceholderFlagMetricsChart />

View File

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

View File

@ -189,9 +189,6 @@ export const MyProjects = forwardRef<
return (
<ContentGridContainer ref={ref}>
<ProjectGrid>
<GridItem gridArea='header'>
<Typography variant='h3'>My projects</Typography>
</GridItem>
<SpacedGridItem gridArea='projects'>
<List
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 = () => {};
//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('10'); // members
await screen.findByText('100'); // features
await screen.findByText('80%'); // health
await screen.findAllByText('80%'); // health
await screen.findByText('Project health');
await screen.findByText('70%'); // avg health past window

View File

@ -1,5 +1,8 @@
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
IconButton,
Link,
@ -19,7 +22,6 @@ import type {
PersonalDashboardSchemaFlagsItem,
PersonalDashboardSchemaProjectsItem,
} from '../../openapi';
import { FlagExposure } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FlagExposure';
import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails';
import useLoading from '../../hooks/useLoading';
import { MyProjects } from './MyProjects';
@ -28,16 +30,10 @@ import {
FlagGrid,
ListItemBox,
listItemStyle,
GridItem,
SpacedGridItem,
} from './Grid';
import { ContentGridNoProjects } from './ContentGridNoProjects';
const ScreenExplanation = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(4),
display: 'flex',
alignItems: 'center',
}));
import ExpandMore from '@mui/icons-material/ExpandMore';
export const StyledCardTitle = styled('div')<{ lines?: number }>(
({ theme, lines = 2 }) => ({
@ -102,6 +98,7 @@ const FlagListItem: FC<{
);
};
// todo: move into own file
const useDashboardState = (
projects: PersonalDashboardSchemaProjectsItem[],
flags: PersonalDashboardSchemaFlagsItem[],
@ -109,11 +106,15 @@ const useDashboardState = (
type State = {
activeProject: string | undefined;
activeFlag: PersonalDashboardSchemaFlagsItem | undefined;
expandProjects: boolean;
expandFlags: boolean;
};
const defaultState = {
const defaultState: State = {
activeProject: undefined,
activeFlag: undefined,
expandProjects: true,
expandFlags: true,
};
const [state, setState] = useLocalStorageState<State>(
@ -121,11 +122,20 @@ const useDashboardState = (
defaultState,
);
const updateState = (newState: Partial<State>) =>
setState({ ...defaultState, ...state, ...newState });
useEffect(() => {
const updates: Partial<State> = {};
const setDefaultFlag =
flags.length &&
(!state.activeFlag ||
!flags.some((flag) => flag.name === state.activeFlag?.name));
if (setDefaultFlag) {
updates.activeFlag = flags[0];
}
const setDefaultProject =
projects.length &&
(!state.activeProject ||
@ -133,37 +143,48 @@ const useDashboardState = (
(project) => project.id === state.activeProject,
));
if (setDefaultFlag || setDefaultProject) {
setState({
activeFlag: setDefaultFlag ? flags[0] : state.activeFlag,
activeProject: setDefaultProject
? projects[0].id
: state.activeProject,
});
if (setDefaultProject) {
updates.activeProject = projects[0].id;
}
});
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 setActiveFlag = (flag: PersonalDashboardSchemaFlagsItem) => {
setState({
...state,
updateState({
activeFlag: flag,
});
};
const setActiveProject = (projectId: string) => {
setState({
...state,
updateState({
activeProject: projectId,
});
};
const toggleSectionState = (section: 'flags' | 'projects') => {
const property = section === 'flags' ? 'expandFlags' : 'expandProjects';
updateState({
[property]: !(state[property] ?? true),
});
};
return {
activeFlag,
setActiveFlag,
activeProject,
setActiveProject,
expandFlags: state.expandFlags ?? true,
expandProjects: state.expandProjects ?? true,
toggleSectionState,
};
};
@ -173,13 +194,57 @@ const WelcomeSection = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
flexFlow: 'row wrap',
alignItems: 'baseline',
marginBottom: theme.spacing(2),
}));
const ViewKeyConceptsButton = styled(Button)(({ theme }) => ({
const ViewKeyConceptsButton = styled(Button)({
fontWeight: 'normal',
padding: 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 = () => {
@ -187,32 +252,30 @@ export const PersonalDashboard = () => {
const name = user?.name;
const {
personalDashboard,
refetch: refetchDashboard,
loading: personalDashboardLoading,
} = usePersonalDashboard();
const { personalDashboard, refetch: refetchDashboard } =
usePersonalDashboard();
const projects = personalDashboard?.projects || [];
const { activeProject, setActiveProject, activeFlag, setActiveFlag } =
useDashboardState(projects, personalDashboard?.flags ?? []);
const {
activeProject,
setActiveProject,
activeFlag,
setActiveFlag,
toggleSectionState,
expandFlags,
expandProjects,
} = useDashboardState(projects, personalDashboard?.flags ?? []);
const [welcomeDialog, setWelcomeDialog] = useLocalStorageState<
'open' | 'closed'
>('welcome-dialog:v1', 'open');
const {
personalDashboardProjectDetails,
loading: loadingDetails,
error: detailsError,
} = usePersonalDashboardProjectDetails(activeProject);
const { personalDashboardProjectDetails, error: detailsError } =
usePersonalDashboardProjectDetails(activeProject);
const activeProjectStage =
personalDashboardProjectDetails?.onboardingStatus.status ?? 'loading';
const setupIncomplete =
activeProjectStage === 'onboarding-started' ||
activeProjectStage === 'first-flag-created';
const noProjects = projects.length === 0;
@ -221,7 +284,7 @@ export const PersonalDashboard = () => {
);
return (
<div>
<MainContent>
<WelcomeSection>
<Typography component='h2' variant='h2'>
Welcome {name}
@ -239,84 +302,113 @@ export const PersonalDashboard = () => {
</ViewKeyConceptsButton>
</WelcomeSection>
{noProjects && personalDashboard ? (
<ContentGridNoProjects
owners={personalDashboard.projectOwners}
admins={personalDashboard.admins}
/>
) : (
<MyProjects
admins={personalDashboard?.admins ?? []}
ref={projectStageRef}
projects={projects}
activeProject={activeProject || ''}
setActiveProject={setActiveProject}
personalDashboardProjectDetails={
personalDashboardProjectDetails
<SectionAccordion
disableGutters
expanded={expandProjects ?? true}
onChange={() => toggleSectionState('projects')}
>
<StyledAccordionSummary
expandIcon={
<ExpandMore titleAccess='Toggle projects section' />
}
/>
)}
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>
<FlagGrid sx={{ mt: 2 }}>
<GridItem
gridArea='title'
sx={{ display: 'flex', alignItems: 'center' }}
>
<Typography variant='h3'>My feature flags</Typography>
</GridItem>
<GridItem
gridArea='lifecycle'
sx={{ display: 'flex', justifyContent: 'flex-end' }}
>
{activeFlag ? (
<FlagExposure
project={activeFlag.project}
flagName={activeFlag.name}
onArchive={refetchDashboard}
/>
) : null}
</GridItem>
<SpacedGridItem gridArea='flags'>
{personalDashboard &&
personalDashboard.flags.length > 0 ? (
<List
disablePadding={true}
sx={{ maxHeight: '400px', overflow: 'auto' }}
>
{personalDashboard.flags.map((flag) => (
<FlagListItem
key={flag.name}
flag={flag}
selected={
flag.name === activeFlag?.name
}
onClick={() => setActiveFlag(flag)}
<SectionAccordion
expanded={expandFlags ?? true}
onChange={() => toggleSectionState('flags')}
>
<StyledAccordionSummary
expandIcon={
<ExpandMore titleAccess='Toggle flags section' />
}
id='flags-panel-header'
aria-controls='flags-panel-content'
>
<Typography variant='body1' component='h3'>
My feature flags
</Typography>
</StyledAccordionSummary>
<StyledAccordionDetails>
<ContentGridContainer>
<FlagGrid>
<SpacedGridItem gridArea='flags'>
{personalDashboard &&
personalDashboard.flags.length > 0 ? (
<List
disablePadding={true}
sx={{
maxHeight: '400px',
overflow: 'auto',
}}
>
{personalDashboard.flags.map((flag) => (
<FlagListItem
key={flag.name}
flag={flag}
selected={
flag.name ===
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>
) : (
<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} />
) : (
<PlaceholderFlagMetricsChart />
)}
</SpacedGridItem>
</FlagGrid>
</ContentGridContainer>
) : (
<PlaceholderFlagMetricsChart />
)}
</SpacedGridItem>
</FlagGrid>
</ContentGridContainer>
</StyledAccordionDetails>
</SectionAccordion>
<WelcomeDialog
open={welcomeDialog === 'open'}
onClose={() => setWelcomeDialog('closed')}
/>
</div>
</MainContent>
);
};