1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

feat: update lifecycle tooltip style (#9107)

New tooltips for lifecycle indicators.
- removed "timeline" lifecycle explanation
- new descriptions
- changed tooltip footer colors
- refactored "environments" section
This commit is contained in:
Tymoteusz Czech 2025-01-16 16:53:03 +01:00 committed by GitHub
parent 013ddd348d
commit 4b3b98f263
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 173 additions and 269 deletions

View File

@ -54,7 +54,7 @@ test('render initial stage', async () => {
await screen.findByText('Define'); await screen.findByText('Define');
await screen.findByText('2 minutes'); await screen.findByText('2 minutes');
await screen.findByText( await screen.findByText(
'This feature flag is currently in the initial phase of its lifecycle.', 'Feature flag has been created, but we have not seen any metrics yet.',
); );
}); });
@ -104,7 +104,7 @@ test('render completed stage with still active', async () => {
}); });
await screen.findByText('Cleanup'); await screen.findByText('Cleanup');
await screen.findByText('production'); await screen.findByText(/production/);
await screen.findByText('2 hours ago'); await screen.findByText('2 hours ago');
expect(screen.queryByText('Archive feature')).not.toBeInTheDocument(); expect(screen.queryByText('Archive feature')).not.toBeInTheDocument();
}); });

View File

@ -14,7 +14,6 @@ import {
DELETE_FEATURE, DELETE_FEATURE,
UPDATE_FEATURE, UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions'; } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { isSafeToArchive } from './isSafeToArchive'; import { isSafeToArchive } from './isSafeToArchive';
import { useLocationSettings } from 'hooks/useLocationSettings'; import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHMS } from 'utils/formatDate'; import { formatDateYMDHMS } from 'utils/formatDate';
@ -27,6 +26,7 @@ const TimeLabel = styled('span')(({ theme }) => ({
const InfoText = styled('p')(({ theme }) => ({ const InfoText = styled('p')(({ theme }) => ({
paddingBottom: theme.spacing(1), paddingBottom: theme.spacing(1),
color: theme.palette.text.primary,
})); }));
const MainLifecycleRow = styled(Box)(({ theme }) => ({ const MainLifecycleRow = styled(Box)(({ theme }) => ({
@ -39,69 +39,21 @@ const TimeLifecycleRow = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: theme.spacing(1.5), marginBottom: theme.spacing(1.5),
gap: theme.spacing(1),
})); }));
const IconsRow = styled(Box)(({ theme }) => ({ const StyledFooter = styled('footer')(({ theme }) => ({
display: 'flex', background: theme.palette.neutral.light,
alignItems: 'center',
marginTop: theme.spacing(4),
marginBottom: theme.spacing(6),
}));
const Line = styled(Box)(({ theme }) => ({
height: '1px',
background: theme.palette.divider,
flex: 1,
}));
const StageBox = styled(Box, {
shouldForwardProp: (prop) => prop !== 'active',
})<{
active?: boolean;
}>(({ theme, active }) => ({
position: 'relative',
// speech bubble triangle for active stage
...(active && {
'&:before': {
content: '""',
position: 'absolute',
display: 'block',
borderStyle: 'solid',
borderColor: `${theme.palette.primary.light} transparent`,
borderWidth: '0 6px 6px',
top: theme.spacing(3.25),
left: theme.spacing(1.75),
},
}),
// stage name text
'&:after': {
content: 'attr(data-after-content)',
display: 'block',
position: 'absolute',
top: theme.spacing(4),
left: theme.spacing(-1.25),
right: theme.spacing(-1.25),
textAlign: 'center',
whiteSpace: 'nowrap',
fontSize: theme.spacing(1.25),
padding: theme.spacing(0.25, 0),
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
// active wrapper for stage name text
...(active && {
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
fontWeight: theme.typography.fontWeightBold,
borderRadius: theme.spacing(0.5),
}),
},
}));
const ColorFill = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
borderRadius: `0 0 ${theme.shape.borderRadiusMedium}px ${theme.shape.borderRadiusMedium}px`, // has to match the parent tooltip container borderRadius: `0 0 ${theme.shape.borderRadiusMedium}px ${theme.shape.borderRadiusMedium}px`, // has to match the parent tooltip container
margin: theme.spacing(-1, -1.5), // has to match the parent tooltip container margin: theme.spacing(-1, -1.5), // has to match the parent tooltip container
padding: theme.spacing(2, 3), padding: theme.spacing(2, 3.5),
}));
const StyledEnvironmentUsageIcon = styled(StyledIconWrapper)(({ theme }) => ({
width: theme.spacing(2),
height: theme.spacing(2),
marginRight: theme.spacing(0.75),
})); }));
const LastSeenIcon: FC<{ const LastSeenIcon: FC<{
@ -111,76 +63,9 @@ const LastSeenIcon: FC<{
const { text, background } = getColor(lastSeen); const { text, background } = getColor(lastSeen);
return ( return (
<StyledIconWrapper style={{ background }}> <StyledEnvironmentUsageIcon style={{ background }}>
<UsageRate stroke={text} /> <UsageRate stroke={text} />
</StyledIconWrapper> </StyledEnvironmentUsageIcon>
);
};
const InitialStageDescription: FC = () => {
return (
<>
<InfoText>
This feature flag is currently in the initial phase of its
lifecycle.
</InfoText>
<InfoText>
This means that the flag has been created, but it has not yet
been seen in any environment.
</InfoText>
<InfoText>
Once we detect metrics for a non-production environment it will
move into pre-live.
</InfoText>
</>
);
};
const StageTimeline: FC<{
stage: LifecycleStage;
}> = ({ stage }) => {
return (
<IconsRow>
<StageBox
data-after-content='Initial'
active={stage.name === 'initial'}
>
<FeatureLifecycleStageIcon stage={{ name: 'initial' }} />
</StageBox>
<Line />
<StageBox
data-after-content='Pre-live'
active={stage.name === 'pre-live'}
>
<FeatureLifecycleStageIcon stage={{ name: 'pre-live' }} />
</StageBox>
<Line />
<StageBox data-after-content='Live' active={stage.name === 'live'}>
<FeatureLifecycleStageIcon stage={{ name: 'live' }} />
</StageBox>
<Line />
<StageBox
data-after-content='Completed'
active={stage.name === 'completed'}
>
<FeatureLifecycleStageIcon stage={{ name: 'completed' }} />
</StageBox>
<Line />
<StageBox
data-after-content='Archived'
active={stage.name === 'archived'}
>
<FeatureLifecycleStageIcon stage={{ name: 'archived' }} />
</StageBox>
</IconsRow>
); );
}; };
@ -189,13 +74,30 @@ const EnvironmentLine = styled(Box)(({ theme }) => ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
marginBottom: theme.spacing(2), marginBottom: theme.spacing(1),
marginLeft: theme.spacing(3.5),
}));
const StyledEnvironmentsTitle = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
color: theme.palette.text.primary,
}));
const StyledEnvironmentIcon = styled(CloudCircle)(({ theme }) => ({
color: theme.palette.primary.main,
width: theme.spacing(2.5),
display: 'block',
})); }));
const CenteredBox = styled(Box)(({ theme }) => ({ const CenteredBox = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing(1), }));
const StyledStageAction = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(2),
})); }));
const Environments: FC<{ const Environments: FC<{
@ -210,12 +112,11 @@ const Environments: FC<{
return ( return (
<EnvironmentLine key={environment.name}> <EnvironmentLine key={environment.name}>
<CenteredBox> <CenteredBox>
<CloudCircle />
<Box>{environment.name}</Box> <Box>{environment.name}</Box>
</CenteredBox> </CenteredBox>
<CenteredBox> <CenteredBox>
<TimeAgo date={environment.lastSeenAt} />
<LastSeenIcon lastSeen={environment.lastSeenAt} /> <LastSeenIcon lastSeen={environment.lastSeenAt} />
<TimeAgo date={environment.lastSeenAt} />
</CenteredBox> </CenteredBox>
</EnvironmentLine> </EnvironmentLine>
); );
@ -224,54 +125,32 @@ const Environments: FC<{
); );
}; };
const PreLiveStageDescription: FC<{ children?: React.ReactNode }> = ({ const StyledStageActionTitle = styled(Typography)(({ theme }) => ({
children, paddingTop: theme.spacing(0.5),
}) => { marginBottom: theme.spacing(0.5),
return ( color: theme.palette.text.primary,
<> fontSize: theme.fontSizes.smallerBody,
<InfoText>
We've seen the feature flag in the following environments:
</InfoText>
{children}
</>
);
};
const ArchivedStageDescription = () => {
return (
<InfoText>
Your feature has been archived, it is now safe to delete it.
</InfoText>
);
};
const BoldTitle = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
fontSize: theme.typography.body2.fontSize,
fontWeight: theme.typography.fontWeightBold, fontWeight: theme.typography.fontWeightBold,
})); }));
const LiveStageDescription: FC<{ const LiveStageAction: FC<{
onComplete: () => void; onComplete: () => void;
loading: boolean; loading: boolean;
children?: React.ReactNode; children?: React.ReactNode;
project: string; project: string;
}> = ({ children, onComplete, loading, project }) => { }> = ({ onComplete, loading, project }) => {
return ( return (
<> <StyledStageAction>
<BoldTitle>Is this feature complete?</BoldTitle> <StyledStageActionTitle>
Is this feature complete?
</StyledStageActionTitle>
<InfoText sx={{ mb: 1 }}> <InfoText sx={{ mb: 1 }}>
Marking the feature flag as complete does not affect any Marking the feature flag as complete does not affect any
configuration; however, it moves the feature flag to its next configuration; however, it moves the flag to its next lifecycle
lifecycle stage and indicates that you have learned what you stage and indicates that you have learned what you needed in
needed in order to progress with the feature. It serves as a order to progress.
reminder to start cleaning up the feature flag and removing it
from the code.
</InfoText> </InfoText>
<PermissionButton <PermissionButton
color='inherit'
variant='outlined' variant='outlined'
permission={UPDATE_FEATURE} permission={UPDATE_FEATURE}
size='small' size='small'
@ -281,13 +160,7 @@ const LiveStageDescription: FC<{
> >
Mark completed Mark completed
</PermissionButton> </PermissionButton>
<InfoText sx={{ mt: 3 }}> </StyledStageAction>
Users have been exposed to this feature in the following
production environments:
</InfoText>
{children}
</>
); );
}; };
@ -298,27 +171,22 @@ const SafeToArchive: FC<{
project: string; project: string;
}> = ({ onArchive, onUncomplete, loading, project }) => { }> = ({ onArchive, onUncomplete, loading, project }) => {
return ( return (
<> <StyledStageAction>
<BoldTitle>Safe to archive</BoldTitle> <StyledStageActionTitle>Safe to archive</StyledStageActionTitle>
<InfoText <InfoText>
sx={{
mt: 2,
mb: 1,
}}
>
We havent seen this feature flag in any environment for at We havent seen this feature flag in any environment for at
least two days. Its likely that its safe to archive this flag. least two days. Its likely that its safe to archive this flag.
</InfoText> </InfoText>
<Box <Box
sx={{ sx={(theme) => ({
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 2, gap: theme.spacing(2),
}} marginTop: theme.spacing(1),
})}
> >
<PermissionButton <PermissionButton
color='inherit'
variant='outlined' variant='outlined'
permission={UPDATE_FEATURE} permission={UPDATE_FEATURE}
size='small' size='small'
@ -329,7 +197,6 @@ const SafeToArchive: FC<{
Revert to live Revert to live
</PermissionButton> </PermissionButton>
<PermissionButton <PermissionButton
color='inherit'
variant='outlined' variant='outlined'
permission={DELETE_FEATURE} permission={DELETE_FEATURE}
size='small' size='small'
@ -340,38 +207,25 @@ const SafeToArchive: FC<{
Archive feature Archive feature
</PermissionButton> </PermissionButton>
</Box> </Box>
</> </StyledStageAction>
); );
}; };
const ActivelyUsed: FC<{ const ActivelyUsed: FC<{
onUncomplete: () => void; onUncomplete: () => void;
loading: boolean; loading: boolean;
children?: React.ReactNode; }> = ({ onUncomplete, loading }) => (
}> = ({ children, onUncomplete, loading }) => ( <StyledStageAction>
<> <InfoText>
<InfoText
sx={{
mt: 1,
mb: 1,
}}
>
This feature has been successfully completed, but we are still This feature has been successfully completed, but we are still
seeing usage. Clean up the feature flag from your code before seeing usage. Clean up the feature flag from your code before
archiving it: archiving it.
</InfoText> </InfoText>
{children} <InfoText>
<InfoText
sx={{
mt: 1,
mb: 1,
}}
>
If you think this feature was completed too early you can revert to If you think this feature was completed too early you can revert to
the live stage: the live stage.
</InfoText> </InfoText>
<PermissionButton <PermissionButton
color='inherit'
variant='outlined' variant='outlined'
permission={UPDATE_FEATURE} permission={UPDATE_FEATURE}
size='small' size='small'
@ -381,7 +235,7 @@ const ActivelyUsed: FC<{
> >
Revert to live Revert to live
</PermissionButton> </PermissionButton>
</> </StyledStageAction>
); );
const CompletedStageDescription: FC<{ const CompletedStageDescription: FC<{
@ -392,34 +246,20 @@ const CompletedStageDescription: FC<{
name: string; name: string;
lastSeenAt: string; lastSeenAt: string;
}>; }>;
children?: React.ReactNode;
project: string; project: string;
}> = ({ }> = ({ environments, onArchive, onUncomplete, loading, project }) => {
children, if (isSafeToArchive(environments)) {
environments,
onArchive,
onUncomplete,
loading,
project,
}) => {
return ( return (
<ConditionallyRender
condition={isSafeToArchive(environments)}
show={
<SafeToArchive <SafeToArchive
onArchive={onArchive} onArchive={onArchive}
onUncomplete={onUncomplete} onUncomplete={onUncomplete}
loading={loading} loading={loading}
project={project} project={project}
/> />
}
elseShow={
<ActivelyUsed onUncomplete={onUncomplete} loading={loading}>
{children}
</ActivelyUsed>
}
/>
); );
}
return <ActivelyUsed onUncomplete={onUncomplete} loading={loading} />;
}; };
const FormatTime: FC<{ const FormatTime: FC<{
@ -438,6 +278,72 @@ const FormatElapsedTime: FC<{
return <span>{elapsedTime}</span>; return <span>{elapsedTime}</span>;
}; };
const StageInfo: FC<{ stage: LifecycleStage['name'] }> = ({ stage }) => {
if (stage === 'initial') {
return (
<InfoText>
Feature flag has been created, but we have not seen any metrics
yet.
</InfoText>
);
}
if (stage === 'pre-live') {
return (
<InfoText>
Feature is being developed and tested in controlled
environments.
</InfoText>
);
}
if (stage === 'live') {
return (
<InfoText>
Feature is being rolled out in production according to an
activation strategy.
</InfoText>
);
}
if (stage === 'completed') {
return (
<InfoText>
When a flag is no longer needed, clean up the code to minimize
technical debt and archive the flag for future reference.
</InfoText>
);
}
if (stage === 'archived') {
return (
<InfoText>
Flag is archived in Unleash for future reference.
</InfoText>
);
}
return null;
};
const EnvironmentsInfo: FC<{
stage: {
name: LifecycleStage['name'];
environments?: Array<{
name: string;
lastSeenAt: string;
}>;
};
}> = ({ stage }) => (
<>
<StyledEnvironmentsTitle>
<StyledEnvironmentIcon />{' '}
{stage.environments && stage.environments.length > 0
? `Seen in environment${stage.environments.length > 1 ? 's' : ''}`
: 'Not seen in any environments'}
</StyledEnvironmentsTitle>
{stage.environments && stage.environments.length > 0 ? (
<Environments environments={stage.environments!} />
) : null}
</>
);
export const FeatureLifecycleTooltip: FC<{ export const FeatureLifecycleTooltip: FC<{
children: React.ReactElement<any, any>; children: React.ReactElement<any, any>;
stage: LifecycleStage; stage: LifecycleStage;
@ -478,9 +384,11 @@ export const FeatureLifecycleTooltip: FC<{
<FeatureLifecycleStageIcon stage={stage} /> <FeatureLifecycleStageIcon stage={stage} />
</Box> </Box>
</MainLifecycleRow> </MainLifecycleRow>
<StageInfo stage={stage.name} />
<TimeLifecycleRow> <TimeLifecycleRow>
<TimeLabel>Stage entered at</TimeLabel> <TimeLabel>Stage entered at</TimeLabel>
<FormatTime time={stage.enteredStageAt} /> <FormatTime time={stage.enteredStageAt} />
</TimeLifecycleRow> </TimeLifecycleRow>
<TimeLifecycleRow> <TimeLifecycleRow>
@ -488,35 +396,31 @@ export const FeatureLifecycleTooltip: FC<{
<FormatElapsedTime time={stage.enteredStageAt} /> <FormatElapsedTime time={stage.enteredStageAt} />
</TimeLifecycleRow> </TimeLifecycleRow>
</Box> </Box>
<ColorFill> {stage.name !== 'archived' ? (
{stage.name === 'initial' && <InitialStageDescription />} <StyledFooter>
{stage.name === 'pre-live' && ( <EnvironmentsInfo stage={stage} />
<PreLiveStageDescription>
<Environments environments={stage.environments} />
</PreLiveStageDescription>
)}
{stage.name === 'live' && ( {stage.name === 'live' && (
<LiveStageDescription <LiveStageAction
onComplete={onComplete} onComplete={onComplete}
loading={loading} loading={loading}
project={project} project={project}
> >
<Environments environments={stage.environments} /> <Environments
</LiveStageDescription> environments={stage.environments!}
/>
</LiveStageAction>
)} )}
{stage.name === 'completed' && ( {stage.name === 'completed' && (
<CompletedStageDescription <CompletedStageDescription
environments={stage.environments} environments={stage.environments!}
onArchive={onArchive} onArchive={onArchive}
onUncomplete={onUncomplete} onUncomplete={onUncomplete}
loading={loading} loading={loading}
project={project} project={project}
> />
<Environments environments={stage.environments} />
</CompletedStageDescription>
)} )}
{stage.name === 'archived' && <ArchivedStageDescription />} </StyledFooter>
</ColorFill> ) : null}
</Box> </Box>
} }
> >