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  Line alignment: before:  after: 
This commit is contained in:
parent
ca831f79e5
commit
23b0401381
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
|
@ -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"
|
||||
`,
|
||||
}),
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user