1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +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('2 minutes');
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('production');
await screen.findByText(/production/);
await screen.findByText('2 hours ago');
expect(screen.queryByText('Archive feature')).not.toBeInTheDocument();
});

View File

@ -14,7 +14,6 @@ import {
DELETE_FEATURE,
UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { isSafeToArchive } from './isSafeToArchive';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHMS } from 'utils/formatDate';
@ -27,6 +26,7 @@ const TimeLabel = styled('span')(({ theme }) => ({
const InfoText = styled('p')(({ theme }) => ({
paddingBottom: theme.spacing(1),
color: theme.palette.text.primary,
}));
const MainLifecycleRow = styled(Box)(({ theme }) => ({
@ -39,69 +39,21 @@ const TimeLifecycleRow = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
marginBottom: theme.spacing(1.5),
gap: theme.spacing(1),
}));
const IconsRow = styled(Box)(({ theme }) => ({
display: 'flex',
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,
// 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,
const StyledFooter = styled('footer')(({ theme }) => ({
background: theme.palette.neutral.light,
color: theme.palette.text.secondary,
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
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<{
@ -111,76 +63,9 @@ const LastSeenIcon: FC<{
const { text, background } = getColor(lastSeen);
return (
<StyledIconWrapper style={{ background }}>
<StyledEnvironmentUsageIcon style={{ background }}>
<UsageRate stroke={text} />
</StyledIconWrapper>
);
};
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>
</StyledEnvironmentUsageIcon>
);
};
@ -189,13 +74,30 @@ const EnvironmentLine = styled(Box)(({ theme }) => ({
alignItems: 'center',
justifyContent: 'space-between',
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 }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const StyledStageAction = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(2),
}));
const Environments: FC<{
@ -210,12 +112,11 @@ const Environments: FC<{
return (
<EnvironmentLine key={environment.name}>
<CenteredBox>
<CloudCircle />
<Box>{environment.name}</Box>
</CenteredBox>
<CenteredBox>
<TimeAgo date={environment.lastSeenAt} />
<LastSeenIcon lastSeen={environment.lastSeenAt} />
<TimeAgo date={environment.lastSeenAt} />
</CenteredBox>
</EnvironmentLine>
);
@ -224,54 +125,32 @@ const Environments: FC<{
);
};
const PreLiveStageDescription: FC<{ children?: React.ReactNode }> = ({
children,
}) => {
return (
<>
<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,
const StyledStageActionTitle = styled(Typography)(({ theme }) => ({
paddingTop: theme.spacing(0.5),
marginBottom: theme.spacing(0.5),
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallerBody,
fontWeight: theme.typography.fontWeightBold,
}));
const LiveStageDescription: FC<{
const LiveStageAction: FC<{
onComplete: () => void;
loading: boolean;
children?: React.ReactNode;
project: string;
}> = ({ children, onComplete, loading, project }) => {
}> = ({ onComplete, loading, project }) => {
return (
<>
<BoldTitle>Is this feature complete?</BoldTitle>
<StyledStageAction>
<StyledStageActionTitle>
Is this feature complete?
</StyledStageActionTitle>
<InfoText sx={{ mb: 1 }}>
Marking the feature flag as complete does not affect any
configuration; however, it moves the feature flag to its next
lifecycle stage and indicates that you have learned what you
needed in order to progress with the feature. It serves as a
reminder to start cleaning up the feature flag and removing it
from the code.
configuration; however, it moves the flag to its next lifecycle
stage and indicates that you have learned what you needed in
order to progress.
</InfoText>
<PermissionButton
color='inherit'
variant='outlined'
permission={UPDATE_FEATURE}
size='small'
@ -281,13 +160,7 @@ const LiveStageDescription: FC<{
>
Mark completed
</PermissionButton>
<InfoText sx={{ mt: 3 }}>
Users have been exposed to this feature in the following
production environments:
</InfoText>
{children}
</>
</StyledStageAction>
);
};
@ -298,27 +171,22 @@ const SafeToArchive: FC<{
project: string;
}> = ({ onArchive, onUncomplete, loading, project }) => {
return (
<>
<BoldTitle>Safe to archive</BoldTitle>
<InfoText
sx={{
mt: 2,
mb: 1,
}}
>
<StyledStageAction>
<StyledStageActionTitle>Safe to archive</StyledStageActionTitle>
<InfoText>
We havent seen this feature flag in any environment for at
least two days. Its likely that its safe to archive this flag.
</InfoText>
<Box
sx={{
sx={(theme) => ({
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
gap: 2,
}}
gap: theme.spacing(2),
marginTop: theme.spacing(1),
})}
>
<PermissionButton
color='inherit'
variant='outlined'
permission={UPDATE_FEATURE}
size='small'
@ -329,7 +197,6 @@ const SafeToArchive: FC<{
Revert to live
</PermissionButton>
<PermissionButton
color='inherit'
variant='outlined'
permission={DELETE_FEATURE}
size='small'
@ -340,38 +207,25 @@ const SafeToArchive: FC<{
Archive feature
</PermissionButton>
</Box>
</>
</StyledStageAction>
);
};
const ActivelyUsed: FC<{
onUncomplete: () => void;
loading: boolean;
children?: React.ReactNode;
}> = ({ children, onUncomplete, loading }) => (
<>
<InfoText
sx={{
mt: 1,
mb: 1,
}}
>
}> = ({ onUncomplete, loading }) => (
<StyledStageAction>
<InfoText>
This feature has been successfully completed, but we are still
seeing usage. Clean up the feature flag from your code before
archiving it:
archiving it.
</InfoText>
{children}
<InfoText
sx={{
mt: 1,
mb: 1,
}}
>
<InfoText>
If you think this feature was completed too early you can revert to
the live stage:
the live stage.
</InfoText>
<PermissionButton
color='inherit'
variant='outlined'
permission={UPDATE_FEATURE}
size='small'
@ -381,7 +235,7 @@ const ActivelyUsed: FC<{
>
Revert to live
</PermissionButton>
</>
</StyledStageAction>
);
const CompletedStageDescription: FC<{
@ -392,34 +246,20 @@ const CompletedStageDescription: FC<{
name: string;
lastSeenAt: string;
}>;
children?: React.ReactNode;
project: string;
}> = ({
children,
environments,
onArchive,
onUncomplete,
loading,
project,
}) => {
return (
<ConditionallyRender
condition={isSafeToArchive(environments)}
show={
<SafeToArchive
onArchive={onArchive}
onUncomplete={onUncomplete}
loading={loading}
project={project}
/>
}
elseShow={
<ActivelyUsed onUncomplete={onUncomplete} loading={loading}>
{children}
</ActivelyUsed>
}
/>
);
}> = ({ environments, onArchive, onUncomplete, loading, project }) => {
if (isSafeToArchive(environments)) {
return (
<SafeToArchive
onArchive={onArchive}
onUncomplete={onUncomplete}
loading={loading}
project={project}
/>
);
}
return <ActivelyUsed onUncomplete={onUncomplete} loading={loading} />;
};
const FormatTime: FC<{
@ -438,6 +278,72 @@ const FormatElapsedTime: FC<{
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<{
children: React.ReactElement<any, any>;
stage: LifecycleStage;
@ -478,9 +384,11 @@ export const FeatureLifecycleTooltip: FC<{
<FeatureLifecycleStageIcon stage={stage} />
</Box>
</MainLifecycleRow>
<StageInfo stage={stage.name} />
<TimeLifecycleRow>
<TimeLabel>Stage entered at</TimeLabel>
<FormatTime time={stage.enteredStageAt} />
</TimeLifecycleRow>
<TimeLifecycleRow>
@ -488,35 +396,31 @@ export const FeatureLifecycleTooltip: FC<{
<FormatElapsedTime time={stage.enteredStageAt} />
</TimeLifecycleRow>
</Box>
<ColorFill>
{stage.name === 'initial' && <InitialStageDescription />}
{stage.name === 'pre-live' && (
<PreLiveStageDescription>
<Environments environments={stage.environments} />
</PreLiveStageDescription>
)}
{stage.name === 'live' && (
<LiveStageDescription
onComplete={onComplete}
loading={loading}
project={project}
>
<Environments environments={stage.environments} />
</LiveStageDescription>
)}
{stage.name === 'completed' && (
<CompletedStageDescription
environments={stage.environments}
onArchive={onArchive}
onUncomplete={onUncomplete}
loading={loading}
project={project}
>
<Environments environments={stage.environments} />
</CompletedStageDescription>
)}
{stage.name === 'archived' && <ArchivedStageDescription />}
</ColorFill>
{stage.name !== 'archived' ? (
<StyledFooter>
<EnvironmentsInfo stage={stage} />
{stage.name === 'live' && (
<LiveStageAction
onComplete={onComplete}
loading={loading}
project={project}
>
<Environments
environments={stage.environments!}
/>
</LiveStageAction>
)}
{stage.name === 'completed' && (
<CompletedStageDescription
environments={stage.environments!}
onArchive={onArchive}
onUncomplete={onUncomplete}
loading={loading}
project={project}
/>
)}
</StyledFooter>
) : null}
</Box>
}
>