mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-23 00:16:25 +01:00
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
import { Box, styled, Typography } from '@mui/material';
|
||
import { Badge } from 'component/common/Badge/Badge';
|
||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||
import type * as React from 'react';
|
||
import type { FC } from 'react';
|
||
import CloudCircle from '@mui/icons-material/CloudCircle';
|
||
import { ReactComponent as UsageRate } from 'assets/icons/usage-rate.svg';
|
||
import { FeatureLifecycleStageIcon } from 'component/common/FeatureLifecycle/FeatureLifecycleStageIcon';
|
||
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
||
import { StyledIconWrapper } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen';
|
||
import { useLastSeenColors } from '../../FeatureEnvironmentSeen/useLastSeenColors';
|
||
import type { LifecycleStage } from './LifecycleStage';
|
||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||
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';
|
||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||
|
||
const TimeLabel = styled('span')(({ theme }) => ({
|
||
color: theme.palette.text.secondary,
|
||
}));
|
||
|
||
const InfoText = styled('p')(({ theme }) => ({
|
||
paddingBottom: theme.spacing(1),
|
||
}));
|
||
|
||
const MainLifecycleRow = styled(Box)(({ theme }) => ({
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
marginBottom: theme.spacing(2),
|
||
}));
|
||
|
||
const TimeLifecycleRow = styled(Box)(({ theme }) => ({
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
marginBottom: theme.spacing(1.5),
|
||
}));
|
||
|
||
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,
|
||
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),
|
||
}));
|
||
|
||
const LastSeenIcon: FC<{
|
||
lastSeen: string;
|
||
}> = ({ lastSeen }) => {
|
||
const getColor = useLastSeenColors();
|
||
const { text, background } = getColor(lastSeen);
|
||
|
||
return (
|
||
<StyledIconWrapper 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>
|
||
);
|
||
};
|
||
|
||
const EnvironmentLine = styled(Box)(({ theme }) => ({
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginTop: theme.spacing(1),
|
||
marginBottom: theme.spacing(2),
|
||
}));
|
||
|
||
const CenteredBox = styled(Box)(({ theme }) => ({
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: theme.spacing(1),
|
||
}));
|
||
|
||
const Environments: FC<{
|
||
environments: Array<{
|
||
name: string;
|
||
lastSeenAt: string;
|
||
}>;
|
||
}> = ({ environments }) => {
|
||
return (
|
||
<Box>
|
||
{environments.map((environment) => {
|
||
return (
|
||
<EnvironmentLine key={environment.name}>
|
||
<CenteredBox>
|
||
<CloudCircle />
|
||
<Box>{environment.name}</Box>
|
||
</CenteredBox>
|
||
<CenteredBox>
|
||
<TimeAgo date={environment.lastSeenAt} />
|
||
<LastSeenIcon lastSeen={environment.lastSeenAt} />
|
||
</CenteredBox>
|
||
</EnvironmentLine>
|
||
);
|
||
})}
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
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,
|
||
fontWeight: theme.typography.fontWeightBold,
|
||
}));
|
||
|
||
const LiveStageDescription: FC<{
|
||
onComplete: () => void;
|
||
loading: boolean;
|
||
children?: React.ReactNode;
|
||
project: string;
|
||
}> = ({ children, onComplete, loading, project }) => {
|
||
return (
|
||
<>
|
||
<BoldTitle>Is this feature complete?</BoldTitle>
|
||
<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.
|
||
</InfoText>
|
||
<PermissionButton
|
||
color='inherit'
|
||
variant='outlined'
|
||
permission={UPDATE_FEATURE}
|
||
size='small'
|
||
onClick={onComplete}
|
||
disabled={loading}
|
||
projectId={project}
|
||
>
|
||
Mark completed
|
||
</PermissionButton>
|
||
<InfoText sx={{ mt: 3 }}>
|
||
Users have been exposed to this feature in the following
|
||
production environments:
|
||
</InfoText>
|
||
|
||
{children}
|
||
</>
|
||
);
|
||
};
|
||
|
||
const SafeToArchive: FC<{
|
||
onArchive: () => void;
|
||
onUncomplete: () => void;
|
||
loading: boolean;
|
||
project: string;
|
||
}> = ({ onArchive, onUncomplete, loading, project }) => {
|
||
return (
|
||
<>
|
||
<BoldTitle>Safe to archive</BoldTitle>
|
||
<InfoText
|
||
sx={{
|
||
mt: 2,
|
||
mb: 1,
|
||
}}
|
||
>
|
||
We haven’t seen this feature flag in any environment for at
|
||
least two days. It’s likely that it’s safe to archive this flag.
|
||
</InfoText>
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<PermissionButton
|
||
color='inherit'
|
||
variant='outlined'
|
||
permission={UPDATE_FEATURE}
|
||
size='small'
|
||
onClick={onUncomplete}
|
||
disabled={loading}
|
||
projectId={project}
|
||
>
|
||
Revert to live
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
color='inherit'
|
||
variant='outlined'
|
||
permission={DELETE_FEATURE}
|
||
size='small'
|
||
sx={{ mb: 2 }}
|
||
onClick={onArchive}
|
||
projectId={project}
|
||
>
|
||
Archive feature
|
||
</PermissionButton>
|
||
</Box>
|
||
</>
|
||
);
|
||
};
|
||
|
||
const ActivelyUsed: FC<{
|
||
onUncomplete: () => void;
|
||
loading: boolean;
|
||
children?: React.ReactNode;
|
||
}> = ({ children, onUncomplete, loading }) => (
|
||
<>
|
||
<InfoText
|
||
sx={{
|
||
mt: 1,
|
||
mb: 1,
|
||
}}
|
||
>
|
||
This feature has been successfully completed, but we are still
|
||
seeing usage. Clean up the feature flag from your code before
|
||
archiving it:
|
||
</InfoText>
|
||
{children}
|
||
<InfoText
|
||
sx={{
|
||
mt: 1,
|
||
mb: 1,
|
||
}}
|
||
>
|
||
If you think this feature was completed too early you can revert to
|
||
the live stage:
|
||
</InfoText>
|
||
<PermissionButton
|
||
color='inherit'
|
||
variant='outlined'
|
||
permission={UPDATE_FEATURE}
|
||
size='small'
|
||
sx={{ mb: 2 }}
|
||
onClick={onUncomplete}
|
||
disabled={loading}
|
||
>
|
||
Revert to live
|
||
</PermissionButton>
|
||
</>
|
||
);
|
||
|
||
const CompletedStageDescription: FC<{
|
||
onArchive: () => void;
|
||
onUncomplete: () => void;
|
||
loading: boolean;
|
||
environments: Array<{
|
||
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>
|
||
}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const FormatTime: FC<{
|
||
time: string;
|
||
}> = ({ time }) => {
|
||
const { locationSettings } = useLocationSettings();
|
||
|
||
return <span>{formatDateYMDHMS(time, locationSettings.locale)}</span>;
|
||
};
|
||
|
||
const FormatElapsedTime: FC<{
|
||
time: string;
|
||
}> = ({ time }) => {
|
||
const pastTime = parseISO(time);
|
||
const elapsedTime = formatDistanceToNow(pastTime, { addSuffix: false });
|
||
return <span>{elapsedTime}</span>;
|
||
};
|
||
|
||
export const FeatureLifecycleTooltip: FC<{
|
||
children: React.ReactElement<any, any>;
|
||
stage: LifecycleStage;
|
||
project: string;
|
||
onArchive: () => void;
|
||
onComplete: () => void;
|
||
onUncomplete: () => void;
|
||
loading: boolean;
|
||
}> = ({
|
||
children,
|
||
stage,
|
||
project,
|
||
onArchive,
|
||
onComplete,
|
||
onUncomplete,
|
||
loading,
|
||
}) => (
|
||
<HtmlTooltip
|
||
maxHeight={800}
|
||
maxWidth={350}
|
||
arrow
|
||
tabIndex={0}
|
||
title={
|
||
<Box>
|
||
<Box sx={(theme) => ({ padding: theme.spacing(2) })}>
|
||
<MainLifecycleRow>
|
||
<Typography variant='h3'>Lifecycle</Typography>
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1,
|
||
}}
|
||
>
|
||
<Badge sx={{ textTransform: 'capitalize' }}>
|
||
{stage.name}
|
||
</Badge>
|
||
<FeatureLifecycleStageIcon stage={stage} />
|
||
</Box>
|
||
</MainLifecycleRow>
|
||
<TimeLifecycleRow>
|
||
<TimeLabel>Stage entered at</TimeLabel>
|
||
|
||
<FormatTime time={stage.enteredStageAt} />
|
||
</TimeLifecycleRow>
|
||
<TimeLifecycleRow>
|
||
<TimeLabel>Time spent in stage</TimeLabel>
|
||
<FormatElapsedTime time={stage.enteredStageAt} />
|
||
</TimeLifecycleRow>
|
||
<StageTimeline stage={stage} />
|
||
</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>
|
||
</Box>
|
||
}
|
||
>
|
||
<CenteredBox>{children}</CenteredBox>
|
||
</HtmlTooltip>
|
||
);
|