diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleStageIcon.tsx b/frontend/src/component/common/FeatureLifecycle/FeatureLifecycleStageIcon.tsx similarity index 62% rename from frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleStageIcon.tsx rename to frontend/src/component/common/FeatureLifecycle/FeatureLifecycleStageIcon.tsx index c3edba31e2b9..856e99b61835 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleStageIcon.tsx +++ b/frontend/src/component/common/FeatureLifecycle/FeatureLifecycleStageIcon.tsx @@ -9,23 +9,39 @@ import { ReactComponent as Stage2 } from 'assets/icons/lifecycle/stage-2.svg'; import { ReactComponent as Stage3 } from 'assets/icons/lifecycle/stage-3.svg'; import { ReactComponent as Stage4 } from 'assets/icons/lifecycle/stage-4.svg'; import { ReactComponent as Stage5 } from 'assets/icons/lifecycle/stage-5.svg'; -import type { LifecycleStage } from './LifecycleStage'; +import type { LifecycleStage } from '../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage'; import { useUiFlag } from 'hooks/useUiFlag'; export const FeatureLifecycleStageIcon: FC<{ stage: Pick; -}> = ({ stage }) => { +}> = ({ stage, ...props }) => { const newIcons = useUiFlag('lifecycleImprovements'); if (stage.name === 'archived') { - return newIcons ? : ; + return newIcons ? ( + + ) : ( + + ); } else if (stage.name === 'pre-live') { - return newIcons ? : ; + return newIcons ? ( + + ) : ( + + ); } else if (stage.name === 'live') { - return newIcons ? : ; + return newIcons ? : ; } else if (stage.name === 'completed') { - return newIcons ? : ; + return newIcons ? ( + + ) : ( + + ); } else { - return newIcons ? : ; + return newIcons ? ( + + ) : ( + + ); } }; diff --git a/frontend/src/component/common/FeatureLifecycle/getFeatureLifecycleName.ts b/frontend/src/component/common/FeatureLifecycle/getFeatureLifecycleName.ts new file mode 100644 index 000000000000..8dd4d8e4560c --- /dev/null +++ b/frontend/src/component/common/FeatureLifecycle/getFeatureLifecycleName.ts @@ -0,0 +1,21 @@ +import type { Lifecycle } from 'interfaces/featureToggle'; + +export const getFeatureLifecycleName = (stage: Lifecycle['stage']): string => { + if (stage === 'initial') { + return 'Define'; + } + if (stage === 'pre-live') { + return 'Develop'; + } + if (stage === 'live') { + return 'Production'; + } + if (stage === 'completed') { + return 'Cleanup'; + } + if (stage === 'archived') { + return 'Archived'; + } + + return stage; +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycle.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycle.tsx index 3df3893dce8f..e8a10ae42ae7 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycle.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycle.tsx @@ -1,10 +1,12 @@ -import { FeatureLifecycleStageIcon } from './FeatureLifecycleStageIcon'; +import { FeatureLifecycleStageIcon } from 'component/common/FeatureLifecycle/FeatureLifecycleStageIcon'; +import { FeatureLifecycleTooltip as LegacyFeatureLifecycleTooltip } from './LegacyFeatureLifecycleTooltip'; import { FeatureLifecycleTooltip } from './FeatureLifecycleTooltip'; import useFeatureLifecycleApi from 'hooks/api/actions/useFeatureLifecycleApi/useFeatureLifecycleApi'; import { populateCurrentStage } from './populateCurrentStage'; import type { FC } from 'react'; import type { Lifecycle } from 'interfaces/featureToggle'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { useUiFlag } from 'hooks/useUiFlag'; export interface LifecycleFeature { lifecycle?: Lifecycle; @@ -25,10 +27,9 @@ export const FeatureLifecycle: FC<{ feature: LifecycleFeature; }> = ({ feature, onComplete, onUncomplete, onArchive }) => { const currentStage = populateCurrentStage(feature); - const { markFeatureUncompleted, loading } = useFeatureLifecycleApi(); - const { trackEvent } = usePlausibleTracker(); + const isLifecycleImprovementsEnabled = useUiFlag('lifecycleImprovements'); const onUncompleteHandler = async () => { await markFeatureUncompleted(feature.name, feature.project); @@ -40,8 +41,23 @@ export const FeatureLifecycle: FC<{ }); }; + if (isLifecycleImprovementsEnabled) { + return currentStage ? ( + + + + ) : null; + } + return currentStage ? ( - - - + + ) : null; }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.test.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.test.tsx index 0504e27f78e8..5ea50e5c802a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.test.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.test.tsx @@ -51,7 +51,7 @@ test('render initial stage', async () => { renderOpenTooltip({ name: 'initial', enteredStageAt }); - await screen.findByText('initial'); + await screen.findByText('Define'); await screen.findByText('2 minutes'); await screen.findByText( 'This feature flag is currently in the initial phase of its lifecycle.', @@ -69,7 +69,7 @@ test('render pre-live stage', async () => { enteredStageAt, }); - await screen.findByText('pre-live'); + await screen.findByText('Develop'); await screen.findByText('development'); await screen.findByText('1 hour ago'); }); @@ -86,8 +86,8 @@ test('render live stage', async () => { }); await screen.findByText('Is this feature complete?'); - await screen.findByText('live'); - await screen.findByText('production'); + await screen.findByText('Production'); + // await screen.findByText('production'); await screen.findByText('2 hours ago'); }); @@ -103,7 +103,7 @@ test('render completed stage with still active', async () => { enteredStageAt, }); - await screen.findByText('completed'); + await screen.findByText('Cleanup'); await screen.findByText('production'); await screen.findByText('2 hours ago'); expect(screen.queryByText('Archive feature')).not.toBeInTheDocument(); @@ -127,7 +127,7 @@ test('render completed stage safe to archive', async () => { onArchive, ); - await screen.findByText('completed'); + await screen.findByText('Cleanup'); const button = await screen.findByText('Archive feature'); button.click(); @@ -153,7 +153,7 @@ test('mark completed button gets activated', async () => { onComplete, ); - await screen.findByText('live'); + await screen.findByText('Production'); const button = await screen.findByText('Mark completed'); button.click(); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.tsx index 4cd4c9545217..11551d728cc1 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleTooltip.tsx @@ -1,11 +1,10 @@ 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 './FeatureLifecycleStageIcon'; +import { FeatureLifecycleStageIcon } from 'component/common/FeatureLifecycle/FeatureLifecycleStageIcon'; import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; import { StyledIconWrapper } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen'; import { useLastSeenColors } from '../../FeatureEnvironmentSeen/useLastSeenColors'; @@ -20,6 +19,7 @@ import { isSafeToArchive } from './isSafeToArchive'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { formatDateYMDHMS } from 'utils/formatDate'; import { formatDistanceToNow, parseISO } from 'date-fns'; +import { getFeatureLifecycleName } from 'component/common/FeatureLifecycle/getFeatureLifecycleName'; const TimeLabel = styled('span')(({ theme }) => ({ color: theme.palette.text.secondary, @@ -472,9 +472,9 @@ export const FeatureLifecycleTooltip: FC<{ gap: 1, }} > - - {stage.name} - + + {getFeatureLifecycleName(stage.name)} + @@ -487,7 +487,6 @@ export const FeatureLifecycleTooltip: FC<{ Time spent in stage - {stage.name === 'initial' && } diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LegacyFeatureLifecycleTooltip.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LegacyFeatureLifecycleTooltip.tsx new file mode 100644 index 000000000000..f4cb1a6b8d8b --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LegacyFeatureLifecycleTooltip.tsx @@ -0,0 +1,526 @@ +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 ( + + + + ); +}; + +const InitialStageDescription: FC = () => { + return ( + <> + + This feature flag is currently in the initial phase of its + lifecycle. + + + This means that the flag has been created, but it has not yet + been seen in any environment. + + + Once we detect metrics for a non-production environment it will + move into pre-live. + + + ); +}; + +const StageTimeline: FC<{ + stage: LifecycleStage; +}> = ({ stage }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +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 ( + + {environments.map((environment) => { + return ( + + + + {environment.name} + + + + + + + ); + })} + + ); +}; + +const PreLiveStageDescription: FC<{ children?: React.ReactNode }> = ({ + children, +}) => { + return ( + <> + + We've seen the feature flag in the following environments: + + + {children} + + ); +}; + +const ArchivedStageDescription = () => { + return ( + + Your feature has been archived, it is now safe to delete it. + + ); +}; + +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 ( + <> + Is this feature complete? + + 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. + + + Mark completed + + + Users have been exposed to this feature in the following + production environments: + + + {children} + + ); +}; + +const SafeToArchive: FC<{ + onArchive: () => void; + onUncomplete: () => void; + loading: boolean; + project: string; +}> = ({ onArchive, onUncomplete, loading, project }) => { + return ( + <> + Safe to archive + + 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. + + + + Revert to live + + + Archive feature + + + + ); +}; + +const ActivelyUsed: FC<{ + onUncomplete: () => void; + loading: boolean; + children?: React.ReactNode; +}> = ({ children, onUncomplete, loading }) => ( + <> + + This feature has been successfully completed, but we are still + seeing usage. Clean up the feature flag from your code before + archiving it: + + {children} + + If you think this feature was completed too early you can revert to + the live stage: + + + Revert to live + + +); + +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 ( + + } + elseShow={ + + {children} + + } + /> + ); +}; + +const FormatTime: FC<{ + time: string; +}> = ({ time }) => { + const { locationSettings } = useLocationSettings(); + + return {formatDateYMDHMS(time, locationSettings.locale)}; +}; + +const FormatElapsedTime: FC<{ + time: string; +}> = ({ time }) => { + const pastTime = parseISO(time); + const elapsedTime = formatDistanceToNow(pastTime, { addSuffix: false }); + return {elapsedTime}; +}; + +export const FeatureLifecycleTooltip: FC<{ + children: React.ReactElement; + stage: LifecycleStage; + project: string; + onArchive: () => void; + onComplete: () => void; + onUncomplete: () => void; + loading: boolean; +}> = ({ + children, + stage, + project, + onArchive, + onComplete, + onUncomplete, + loading, +}) => ( + + ({ padding: theme.spacing(2) })}> + + Lifecycle + + + {stage.name} + + + + + + Stage entered at + + + + + Time spent in stage + + + + + + {stage.name === 'initial' && } + {stage.name === 'pre-live' && ( + + + + )} + {stage.name === 'live' && ( + + + + )} + {stage.name === 'completed' && ( + + + + )} + {stage.name === 'archived' && } + + + } + > + {children} + +); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx index e3d14f0a1e83..2a994dead7e3 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx @@ -1,19 +1,21 @@ +import type { Lifecycle } from 'interfaces/featureToggle'; + type TimedStage = { enteredStageAt: string }; export type LifecycleStage = TimedStage & ( - | { name: 'initial' } + | { name: 'initial' & Lifecycle['stage'] } | { - name: 'pre-live'; + name: 'pre-live' & Lifecycle['stage']; environments: Array<{ name: string; lastSeenAt: string }>; } | { - name: 'live'; + name: 'live' & Lifecycle['stage']; environments: Array<{ name: string; lastSeenAt: string }>; } | { - name: 'completed'; + name: 'completed' & Lifecycle['stage']; environments: Array<{ name: string; lastSeenAt: string }>; status: 'kept' | 'discarded'; } - | { name: 'archived' } + | { name: 'archived' & Lifecycle['stage'] } ); diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx index a9da09ae141e..da3c0b75b346 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx @@ -1,5 +1,5 @@ import { styled } from '@mui/material'; -import { FeatureLifecycleStageIcon } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleStageIcon'; +import { FeatureLifecycleStageIcon } from 'component/common/FeatureLifecycle/FeatureLifecycleStageIcon'; import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus'; import useLoading from 'hooks/useLoading'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; @@ -9,10 +9,13 @@ import type { ProjectStatusSchemaLifecycleSummary } from 'openapi'; import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; import { lifecycleMessages } from './LifecycleMessages'; import InfoIcon from '@mui/icons-material/Info'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { getFeatureLifecycleName } from 'component/common/FeatureLifecycle/getFeatureLifecycleName'; const LifecycleBoxContent = styled('div')(({ theme }) => ({ padding: theme.spacing(2), - gap: theme.spacing(4), + gap: theme.spacing(2), + minHeight: '100%', display: 'flex', flexFlow: 'column', justifyContent: 'space-between', @@ -96,6 +99,10 @@ const Stats = styled('dl')(({ theme }) => ({ }, })); +const StyledStageTitle = styled('span')(({ theme }) => ({ + fontSize: theme.typography.body2.fontSize, +})); + const NoData = styled('span')({ fontWeight: 'normal', }); @@ -134,21 +141,41 @@ const BigNumber: FC<{ value?: number }> = ({ value }) => { ); }; + export const ProjectLifecycleSummary = () => { const projectId = useRequiredPathParam('projectId'); const { data, loading } = useProjectStatus(projectId); + const isLifecycleImprovementsEnabled = useUiFlag('lifecycleImprovements'); const loadingRef = useLoading( loading, '[data-loading-project-lifecycle-summary=true]', ); + const flagWord = (stage: keyof ProjectStatusSchemaLifecycleSummary) => { - if (data?.lifecycleSummary[stage].currentFlags === 1) { - return 'flag'; - } else { - return 'flags'; + const hasOneFlag = data?.lifecycleSummary[stage].currentFlags === 1; + + if (hasOneFlag) { + return isLifecycleImprovementsEnabled ? 'Flag' : 'flag'; } + + return isLifecycleImprovementsEnabled ? 'Flags' : 'flags'; + }; + + const stageName = (stage: keyof ProjectStatusSchemaLifecycleSummary) => { + if (!isLifecycleImprovementsEnabled) { + return `${flagWord('initial')} in ${stage}`; + } + + const lifecycleStageName = stage === 'preLive' ? 'pre-live' : stage; + return ( + + {flagWord(stage)} in{' '} + {getFeatureLifecycleName(lifecycleStageName)} stage + + ); }; + return ( @@ -163,7 +190,7 @@ export const ProjectLifecycleSummary = () => { stage={{ name: 'initial' }} /> - {flagWord('initial')} in initial + {stageName('initial')}

{ stage={{ name: 'pre-live' }} /> - {flagWord('preLive')} in pre-live + {stageName('preLive')}

{ stage={{ name: 'live' }} /> - {flagWord('live')} in live + {stageName('live')}

{ stage={{ name: 'completed' }} /> - {flagWord('completed')} in completed + {stageName('completed')}

{ stage={{ name: 'archived' }} /> - {flagWord('archived')} in archived + {stageName('archived')}

Last 30 days
diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index 22658f25859c..6dde6cd31cd2 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -1,4 +1,4 @@ -import type { CreateFeatureSchemaType } from 'openapi'; +import type { CreateFeatureSchemaType, FeatureSchema } from 'openapi'; import type { IFeatureStrategy } from './strategy'; import type { ITag } from './tags'; @@ -34,7 +34,7 @@ export type ILastSeenEnvironments = Pick< >; export type Lifecycle = { - stage: 'initial' | 'pre-live' | 'live' | 'completed' | 'archived'; + stage: Required['lifecycle']['stage']; status?: string; enteredStageAt: string; };