1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

chore: release plans flow in flag environments (#8843)

https://linear.app/unleash/issue/2-2816/add-release-plan-to-feature-flag-from-release-template

https://linear.app/unleash/issue/2-2818/list-release-plan-with-milestones-in-feature-flag-environment-section

https://linear.app/unleash/issue/2-2819/removing-release-plan-from-feature

Implements the release plan flow in the feature flag environment.

You can now manage release plans in a feature flag environment by adding
or removing them, as well as start milestones.


https://github.com/user-attachments/assets/24db9db4-7c3a-463e-b48a-611358f2b212
This commit is contained in:
Nuno Góis 2024-11-26 09:15:24 +00:00 committed by GitHub
parent 26d96a0002
commit 14403d7836
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 771 additions and 148 deletions

View File

@ -6,6 +6,7 @@ import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
const StyledIcon = styled('div')(({ theme }) => ({
width: theme.spacing(4),
@ -60,6 +61,7 @@ export const FeatureReleasePlanCard = ({
}: IFeatureReleasePlanCardProps) => {
const Icon = getFeatureStrategyIcon('releasePlanTemplate');
const { trackEvent } = usePlausibleTracker();
const { refetch } = useReleasePlans(projectId, featureId, environmentId);
const { addReleasePlanToFeature } = useReleasePlansApi();
const { setToastApiError, setToastData } = useToast();
@ -75,6 +77,7 @@ export const FeatureReleasePlanCard = ({
type: 'success',
title: 'Release plan added',
});
refetch();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}

View File

@ -21,6 +21,10 @@ import usePagination from 'hooks/usePagination';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { ReleasePlan } from '../../../ReleasePlan/ReleasePlan';
import { Badge } from 'component/common/Badge/Badge';
import { SectionSeparator } from '../SectionSeparator/SectionSeparator';
interface IEnvironmentAccordionBodyProps {
isDisabled: boolean;
@ -40,6 +44,14 @@ const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({
},
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
border: 'none',
padding: theme.spacing(0.75, 1.5),
borderRadius: theme.shape.borderRadiusLarge,
color: theme.palette.common.white,
}));
const EnvironmentAccordionBody = ({
featureEnvironment,
isDisabled,
@ -58,6 +70,11 @@ const EnvironmentAccordionBody = ({
const [strategies, setStrategies] = useState(
featureEnvironment?.strategies || [],
);
const { releasePlans } = useReleasePlans(
projectId,
featureId,
featureEnvironment?.name,
);
const { trackEvent } = usePlausibleTracker();
const [dragItem, setDragItem] = useState<{
@ -201,7 +218,10 @@ const EnvironmentAccordionBody = ({
<StyledAccordionBody>
<StyledAccordionBodyInnerContainer>
<ConditionallyRender
condition={strategies.length > 0 && isDisabled}
condition={
(releasePlans.length > 0 || strategies.length > 0) &&
isDisabled
}
show={() => (
<Alert severity='warning' sx={{ mb: 2 }}>
This environment is disabled, which means that none
@ -210,74 +230,97 @@ const EnvironmentAccordionBody = ({
)}
/>
<ConditionallyRender
condition={strategies.length > 0}
condition={releasePlans.length > 0 || strategies.length > 0}
show={
<ConditionallyRender
condition={
strategies.length < 50 ||
!manyStrategiesPagination
}
show={
<>
{strategies.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index}
environmentName={
featureEnvironment.name
<>
{releasePlans.map((plan) => (
<ReleasePlan key={plan.id} plan={plan} />
))}
<ConditionallyRender
condition={
releasePlans.length > 0 &&
strategies.length > 0
}
show={
<SectionSeparator>
<StyledBadge>OR</StyledBadge>
</SectionSeparator>
}
/>
<ConditionallyRender
condition={
strategies.length < 50 ||
!manyStrategiesPagination
}
show={
<>
{strategies.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={
dragItem?.id === strategy.id
}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(
strategy.id,
)}
onDragEnd={onDragEnd}
/>
))}
</>
}
elseShow={
<>
<Alert severity='error'>
We noticed you're using a high
number of activation strategies. To
ensure a more targeted approach,
consider leveraging constraints or
segments.
</Alert>
<br />
{page.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={
index + pageIndex * pageSize
}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={false}
onDragStartRef={
(() => {}) as any
}
onDragOver={(() => {}) as any}
onDragEnd={(() => {}) as any}
/>
))}
<br />
<Pagination
count={pages.length}
shape='rounded'
page={pageIndex + 1}
onChange={(_, page) =>
setPageIndex(page - 1)
}
otherEnvironments={
otherEnvironments
}
isDragging={
dragItem?.id === strategy.id
}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(strategy.id)}
onDragEnd={onDragEnd}
/>
))}
</>
}
elseShow={
<>
<Alert severity='error'>
We noticed you're using a high number of
activation strategies. To ensure a more
targeted approach, consider leveraging
constraints or segments.
</Alert>
<br />
{page.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index + pageIndex * pageSize}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={false}
onDragStartRef={(() => {}) as any}
onDragOver={(() => {}) as any}
onDragEnd={(() => {}) as any}
/>
))}
<br />
<Pagination
count={pages.length}
shape='rounded'
page={pageIndex + 1}
onChange={(_, page) =>
setPageIndex(page - 1)
}
/>
</>
}
/>
</>
}
/>
</>
}
elseShow={
<FeatureStrategyEmpty

View File

@ -1,6 +1,12 @@
import type { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle';
import { FeatureMetricsStats } from 'component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats';
import { SectionSeparator } from '../SectionSeparator/SectionSeparator';
import { styled } from '@mui/material';
const StyledLabel = styled('span')(({ theme }) => ({
background: theme.palette.envAccordion.expanded,
padding: theme.spacing(0, 2),
}));
interface IEnvironmentFooterProps {
environmentMetric?: IFeatureEnvironmentMetrics;
@ -15,7 +21,9 @@ export const EnvironmentFooter = ({
return (
<>
<SectionSeparator>Feature flag exposure</SectionSeparator>
<SectionSeparator>
<StyledLabel>Feature flag exposure</StyledLabel>
</SectionSeparator>
<div>
<FeatureMetricsStats

View File

@ -23,7 +23,6 @@ const SeparatorContent = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
textAlign: 'center',
padding: '0 1rem',
background: theme.palette.envAccordion.expanded,
position: 'relative',
maxWidth: '80%',
color: theme.palette.text.primary,

View File

@ -22,6 +22,10 @@ import type { IFeatureStrategy } from 'interfaces/strategy';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
import isEqual from 'lodash/isEqual';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { ReleasePlan } from '../ReleasePlan/ReleasePlan';
import { SectionSeparator } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator';
import { Badge } from 'component/common/Badge/Badge';
interface IEnvironmentAccordionBodyProps {
isDisabled: boolean;
@ -41,6 +45,14 @@ const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({
},
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
border: 'none',
padding: theme.spacing(0.75, 1.5),
borderRadius: theme.shape.borderRadiusLarge,
color: theme.palette.common.white,
}));
export const FeatureOverviewEnvironmentBody = ({
featureEnvironment,
isDisabled,
@ -59,6 +71,11 @@ export const FeatureOverviewEnvironmentBody = ({
const [strategies, setStrategies] = useState(
featureEnvironment?.strategies || [],
);
const { releasePlans } = useReleasePlans(
projectId,
featureId,
featureEnvironment?.name,
);
const { trackEvent } = usePlausibleTracker();
const [dragItem, setDragItem] = useState<{
@ -215,7 +232,11 @@ export const FeatureOverviewEnvironmentBody = ({
<StyledAccordionBody>
<StyledAccordionBodyInnerContainer>
<ConditionallyRender
condition={strategiesToDisplay.length > 0 && isDisabled}
condition={
(releasePlans.length > 0 ||
strategiesToDisplay.length > 0) &&
isDisabled
}
show={() => (
<Alert severity='warning' sx={{ mb: 2 }}>
This environment is disabled, which means that none
@ -224,78 +245,105 @@ export const FeatureOverviewEnvironmentBody = ({
)}
/>
<ConditionallyRender
condition={strategiesToDisplay.length > 0}
condition={
releasePlans.length > 0 ||
strategiesToDisplay.length > 0
}
show={
<ConditionallyRender
condition={
strategiesToDisplay.length < 50 ||
!manyStrategiesPagination
}
show={
<>
{strategiesToDisplay.map(
(strategy, index) => (
<>
{releasePlans.map((plan) => (
<ReleasePlan key={plan.id} plan={plan} />
))}
<ConditionallyRender
condition={
releasePlans.length > 0 &&
strategies.length > 0
}
show={
<SectionSeparator>
<StyledBadge>OR</StyledBadge>
</SectionSeparator>
}
/>
<ConditionallyRender
condition={
strategiesToDisplay.length < 50 ||
!manyStrategiesPagination
}
show={
<>
{strategiesToDisplay.map(
(strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={
dragItem?.id ===
strategy.id
}
onDragStartRef={
onDragStartRef
}
onDragOver={onDragOver(
strategy.id,
)}
onDragEnd={onDragEnd}
/>
),
)}
</>
}
elseShow={
<>
<Alert severity='error'>
We noticed you're using a high
number of activation strategies. To
ensure a more targeted approach,
consider leveraging constraints or
segments.
</Alert>
<br />
{page.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index}
index={
index + pageIndex * pageSize
}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={
dragItem?.id === strategy.id
isDragging={false}
onDragStartRef={
(() => {}) as any
}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(
strategy.id,
)}
onDragEnd={onDragEnd}
onDragOver={(() => {}) as any}
onDragEnd={(() => {}) as any}
/>
),
)}
</>
}
elseShow={
<>
<Alert severity='error'>
We noticed you're using a high number of
activation strategies. To ensure a more
targeted approach, consider leveraging
constraints or segments.
</Alert>
<br />
{page.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index + pageIndex * pageSize}
environmentName={
featureEnvironment.name
))}
<br />
<Pagination
count={pages.length}
shape='rounded'
page={pageIndex + 1}
onChange={(_, page) =>
setPageIndex(page - 1)
}
otherEnvironments={
otherEnvironments
}
isDragging={false}
onDragStartRef={(() => {}) as any}
onDragOver={(() => {}) as any}
onDragEnd={(() => {}) as any}
/>
))}
<br />
<Pagination
count={pages.length}
shape='rounded'
page={pageIndex + 1}
onChange={(_, page) =>
setPageIndex(page - 1)
}
/>
</>
}
/>
</>
}
/>
</>
}
elseShow={
<FeatureStrategyEmpty

View File

@ -0,0 +1,193 @@
import Delete from '@mui/icons-material/Delete';
import { styled } from '@mui/material';
import { DELETE_FEATURE_STRATEGY } from '@server/types/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useToast from 'hooks/useToast';
import type {
IReleasePlan,
IReleasePlanMilestone,
} from 'interfaces/releasePlans';
import { useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ReleasePlanRemoveDialog } from './ReleasePlanRemoveDialog';
import { ReleasePlanMilestone } from './ReleasePlanMilestone';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean }>(({ theme, disabled }) => ({
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusMedium,
border: `1px solid ${theme.palette.divider}`,
'& + &': {
marginTop: theme.spacing(2),
},
background: disabled
? theme.palette.envAccordion.disabled
: theme.palette.background.paper,
}));
const StyledHeader = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean }>(({ theme, disabled }) => ({
display: 'flex',
justifyContent: 'space-between',
color: disabled ? theme.palette.text.secondary : theme.palette.text.primary,
}));
const StyledHeaderTitleContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: theme.spacing(1),
}));
const StyledHeaderTitleLabel = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
lineHeight: 0.5,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(0.5),
}));
const StyledHeaderDescription = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
lineHeight: 0.5,
color: theme.palette.text.secondary,
}));
const StyledBody = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(3),
}));
const StyledConnection = styled('div')(({ theme }) => ({
width: 4,
height: theme.spacing(2),
backgroundColor: theme.palette.divider,
marginLeft: theme.spacing(3.25),
}));
interface IReleasePlanProps {
plan: IReleasePlan;
}
export const ReleasePlan = ({ plan }: IReleasePlanProps) => {
const {
id,
name,
description,
activeMilestoneId,
featureName,
environment,
milestones,
} = plan;
const projectId = useRequiredPathParam('projectId');
const { refetch } = useReleasePlans(projectId, featureName, environment);
const { removeReleasePlanFromFeature, startReleasePlanMilestone } =
useReleasePlansApi();
const { setToastData, setToastApiError } = useToast();
const [removeOpen, setRemoveOpen] = useState(false);
const onRemoveConfirm = async () => {
try {
await removeReleasePlanFromFeature(
projectId,
featureName,
environment,
id,
);
setToastData({
title: `Release plan "${name}" has been removed from ${featureName} in ${environment}`,
type: 'success',
});
refetch();
setRemoveOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onStartMilestone = async (milestone: IReleasePlanMilestone) => {
try {
await startReleasePlanMilestone(
projectId,
featureName,
environment,
id,
milestone.id,
);
setToastData({
title: `Milestone "${milestone.name}" has started`,
type: 'success',
});
refetch();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const disabled = !activeMilestoneId;
const activeIndex = milestones.findIndex(
(milestone) => milestone.id === activeMilestoneId,
);
return (
<StyledContainer disabled={disabled}>
<StyledHeader disabled={disabled}>
<StyledHeaderTitleContainer>
<StyledHeaderTitleLabel>
Release plan
</StyledHeaderTitleLabel>
<span>{name}</span>
<StyledHeaderDescription>
{description}
</StyledHeaderDescription>
</StyledHeaderTitleContainer>
<PermissionIconButton
onClick={() => setRemoveOpen(true)}
permission={DELETE_FEATURE_STRATEGY}
environmentId={environment}
projectId={projectId}
tooltipProps={{
title: 'Remove release plan',
}}
>
<Delete />
</PermissionIconButton>
</StyledHeader>
<StyledBody>
{milestones.map((milestone, index) => (
<div key={milestone.id}>
<ReleasePlanMilestone
milestone={milestone}
status={
milestone.id === activeMilestoneId
? 'active'
: index < activeIndex
? 'completed'
: 'not-started'
}
onStartMilestone={onStartMilestone}
/>
<ConditionallyRender
condition={index < milestones.length - 1}
show={<StyledConnection />}
/>
</div>
))}
</StyledBody>
<ReleasePlanRemoveDialog
plan={plan}
open={removeOpen}
setOpen={setRemoveOpen}
onConfirm={onRemoveConfirm}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,146 @@
import ExpandMore from '@mui/icons-material/ExpandMore';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Link,
styled,
} from '@mui/material';
import PlayCircleIcon from '@mui/icons-material/PlayCircle';
import TripOriginIcon from '@mui/icons-material/TripOrigin';
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ReleasePlanMilestoneStrategy } from './ReleasePlanMilestoneStrategy';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
type MilestoneStatus = 'not-started' | 'active' | 'completed';
const StyledAccordion = styled(Accordion, {
shouldForwardProp: (prop) => prop !== 'status',
})<{ status: MilestoneStatus }>(({ theme, status }) => ({
border: `1px solid ${status === 'active' ? theme.palette.success.border : theme.palette.divider}`,
boxShadow: 'none',
margin: 0,
backgroundColor: theme.palette.background.paper,
'&:before': {
display: 'none',
},
}));
const StyledAccordionSummary = styled(AccordionSummary)({
'& .MuiAccordionSummary-content': {
justifyContent: 'space-between',
alignItems: 'center',
},
});
const StyledTitleContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'start',
flexDirection: 'column',
gap: theme.spacing(0.5),
}));
const StyledTitle = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
}));
const StyledStatus = styled('div', {
shouldForwardProp: (prop) => prop !== 'status',
})<{ status: MilestoneStatus }>(({ theme, status }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
paddingRight: theme.spacing(1),
fontSize: theme.fontSizes.smallerBody,
borderRadius: theme.shape.borderRadiusMedium,
backgroundColor:
status === 'active' ? theme.palette.success.light : 'transparent',
color:
status === 'active'
? theme.palette.success.contrastText
: status === 'completed'
? theme.palette.text.secondary
: theme.palette.text.primary,
'& svg': {
color:
status === 'active'
? theme.palette.success.main
: status === 'completed'
? theme.palette.neutral.border
: theme.palette.primary.main,
},
}));
const StyledSecondaryLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
}));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
backgroundColor: theme.palette.envAccordion.expanded,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
}));
interface IReleasePlanMilestoneProps {
milestone: IReleasePlanMilestone;
status: MilestoneStatus;
onStartMilestone: (milestone: IReleasePlanMilestone) => void;
}
export const ReleasePlanMilestone = ({
milestone,
status,
onStartMilestone,
}: IReleasePlanMilestoneProps) => {
const statusText =
status === 'active'
? 'Running'
: status === 'completed'
? 'Restart'
: 'Start';
return (
<StyledAccordion status={status}>
<StyledAccordionSummary expandIcon={<ExpandMore />}>
<StyledTitleContainer>
<StyledTitle>{milestone.name}</StyledTitle>
<StyledStatus status={status}>
<ConditionallyRender
condition={status === 'active'}
show={<TripOriginIcon />}
elseShow={<PlayCircleIcon />}
/>
<ConditionallyRender
condition={status === 'active'}
show={<span>{statusText}</span>}
elseShow={
<Link
onClick={(e) => {
e.stopPropagation();
onStartMilestone(milestone);
}}
>
{statusText}
</Link>
}
/>
</StyledStatus>
</StyledTitleContainer>
<StyledSecondaryLabel>View strategies</StyledSecondaryLabel>
</StyledAccordionSummary>
<StyledAccordionDetails>
{milestone.strategies.map((strategy, index) => (
<div key={strategy.id}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='OR' />}
/>
<ReleasePlanMilestoneStrategy strategy={strategy} />
</div>
))}
</StyledAccordionDetails>
</StyledAccordion>
);
};

View File

@ -0,0 +1,55 @@
import { Box, styled } from '@mui/material';
import { StrategyExecution } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import SplitPreviewSlider from 'component/feature/StrategyTypes/SplitPreviewSlider/SplitPreviewSlider';
import {
formatStrategyName,
getFeatureStrategyIcon,
} from 'utils/strategyNames';
import type { IFeatureStrategy } from 'interfaces/strategy';
const StyledStrategy = styled('div')(({ theme }) => ({
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
padding: theme.spacing(2),
}));
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
color: theme.palette.text.primary,
'& > svg': {
fill: theme.palette.action.disabled,
},
marginBottom: theme.spacing(1),
}));
interface IReleasePlanMilestoneStrategyProps {
strategy: IFeatureStrategy;
}
export const ReleasePlanMilestoneStrategy = ({
strategy,
}: IReleasePlanMilestoneStrategyProps) => {
const Icon = getFeatureStrategyIcon(strategy.strategyName);
return (
<StyledStrategy>
<StyledHeader>
<Icon />
{`${formatStrategyName(String(strategy.strategyName))}${strategy.title ? `: ${strategy.title}` : ''}`}
</StyledHeader>
<StrategyExecution strategy={strategy} />
{strategy.variants &&
strategy.variants.length > 0 &&
(strategy.disabled ? (
<Box sx={{ opacity: '0.5' }}>
<SplitPreviewSlider variants={strategy.variants} />
</Box>
) : (
<SplitPreviewSlider variants={strategy.variants} />
))}
</StyledStrategy>
);
};

View File

@ -0,0 +1,43 @@
import { Alert } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import type { IReleasePlan } from 'interfaces/releasePlans';
interface IReleasePlanRemoveDialogProps {
plan: IReleasePlan;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: () => void;
}
export const ReleasePlanRemoveDialog = ({
plan,
open,
setOpen,
onConfirm,
}: IReleasePlanRemoveDialogProps) => (
<Dialogue
title='Remove release plan?'
open={open}
primaryButtonText='Remove release plan'
secondaryButtonText='Cancel'
onClick={onConfirm}
onClose={() => setOpen(false)}
>
<ConditionallyRender
condition={Boolean(plan.activeMilestoneId)}
show={
<Alert severity='error' sx={{ mb: 2 }}>
This release plan currently has one active milestone.
Removing the release plan will change which users receive
access to the feature.
</Alert>
}
/>
<p>
You are about to remove release plan <strong>{plan.name}</strong>{' '}
from <strong>{plan.featureName}</strong> in{' '}
<strong>{plan.environment}</strong>
</p>
</Dialogue>
);

View File

@ -58,7 +58,7 @@ interface IMilestoneCardProps {
milestoneNameChanged: (milestoneId: string, name: string) => void;
showAddStrategyDialog: (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void;
errors: { [key: string]: string };
clearErrors: () => void;
@ -84,7 +84,7 @@ export const MilestoneCard = ({
const onSelectStrategy = (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
showAddStrategyDialog(milestone.id, strategy);
};

View File

@ -15,7 +15,7 @@ interface IMilestoneListProps {
>;
openAddStrategyForm: (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void;
errors: { [key: string]: string };
clearErrors: () => void;

View File

@ -51,7 +51,9 @@ const StyledCard = styled('div')(({ theme }) => ({
interface IMilestoneStrategyMenuCardProps {
strategy: IStrategy;
onClick: (strategy: IReleasePlanMilestoneStrategy) => void;
onClick: (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void;
}
export const MilestoneStrategyMenuCard = ({

View File

@ -12,7 +12,7 @@ interface IMilestoneStrategyMenuCardsProps {
milestoneId: string;
openAddStrategy: (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void;
}
@ -26,7 +26,9 @@ export const MilestoneStrategyMenuCards = ({
(strategy) => !strategy.deprecated && !strategy.editable,
);
const onClick = (strategy: IReleasePlanMilestoneStrategy) => {
const onClick = (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
openAddStrategy(milestoneId, strategy);
};

View File

@ -66,10 +66,10 @@ const StyledTargetingHeader = styled('div')(({ theme }) => ({
interface IReleasePlanTemplateAddStrategyFormProps {
milestoneId: string | undefined;
onCancel: () => void;
strategy: IReleasePlanMilestoneStrategy;
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>;
onAddStrategy: (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void;
}
@ -135,7 +135,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
{activeTab === 0 && (
<>
<MilestoneStrategyTitle
title={addStrategy.title}
title={addStrategy.title || ''}
setTitle={(title) =>
updateParameter('title', title)
}

View File

@ -62,7 +62,9 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
const [activeMilestoneId, setActiveMilestoneId] = useState<
string | undefined
>();
const [strategy, setStrategy] = useState<IReleasePlanMilestoneStrategy>({
const [strategy, setStrategy] = useState<
Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>
>({
name: 'flexibleRollout',
parameters: { rollout: '50' },
constraints: [],
@ -71,7 +73,7 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
});
const openAddStrategyForm = (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
setActiveMilestoneId(milestoneId);
setStrategy(strategy);
@ -80,7 +82,7 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
const addStrategy = (
milestoneId: string,
strategy: IReleasePlanMilestoneStrategy,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
setMilestones((prev) =>
prev.map((milestone, i) =>

View File

@ -25,7 +25,36 @@ export const useReleasePlansApi = () => {
await makeRequest(req.caller, req.id);
};
const removeReleasePlanFromFeature = async (
projectId: string,
featureName: string,
environment: string,
releasePlanId: string,
): Promise<void> => {
const requestId = 'removeReleasePlanFromFeature';
const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/release_plans/${releasePlanId}`;
const req = createRequest(path, { method: 'DELETE' }, requestId);
await makeRequest(req.caller, req.id);
};
const startReleasePlanMilestone = async (
projectId: string,
featureName: string,
environment: string,
releasePlanId: string,
milestoneId: string,
): Promise<void> => {
const requestId = 'startReleasePlanMilestone';
const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/release_plans/${releasePlanId}/milestones/${milestoneId}/start`;
const req = createRequest(path, { method: 'POST' }, requestId);
await makeRequest(req.caller, req.id);
};
return {
addReleasePlanToFeature,
removeReleasePlanFromFeature,
startReleasePlanMilestone,
};
};

View File

@ -0,0 +1,43 @@
import { useMemo } from 'react';
import useUiConfig from '../useUiConfig/useUiConfig';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import { useUiFlag } from 'hooks/useUiFlag';
import type { IReleasePlan } from 'interfaces/releasePlans';
const DEFAULT_DATA: IReleasePlan[] = [];
export const useReleasePlans = (
projectId: string,
featureName: string,
environment?: string,
) => {
const { isEnterprise } = useUiConfig();
const releasePlansEnabled = useUiFlag('releasePlans');
const { data, error, mutate } = useConditionalSWR<IReleasePlan[]>(
isEnterprise() && releasePlansEnabled && Boolean(environment),
DEFAULT_DATA,
formatApiPath(
`api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/release_plans`,
),
fetcher,
);
return useMemo(
() => ({
releasePlans: data ?? [],
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Release plans'))
.then((res) => res.json());
};

View File

@ -1,5 +1,4 @@
import type { IFeatureVariant } from './featureToggle';
import type { IConstraint, IFeatureStrategyParameters } from './strategy';
import type { IFeatureStrategy } from './strategy';
export interface IReleasePlanTemplate {
id: string;
@ -18,19 +17,27 @@ export interface IReleasePlanTemplate {
milestones: IReleasePlanMilestonePayload[];
}
export interface IReleasePlanMilestoneStrategy {
export interface IReleasePlan {
id: string;
name: string;
title: string;
disabled?: boolean;
constraints: IConstraint[];
parameters: IFeatureStrategyParameters;
variants?: IFeatureVariant[];
description: string;
createdAt: string;
createdByUserId: number;
activeMilestoneId?: string;
featureName: string;
environment: string;
milestones: IReleasePlanMilestone[];
}
export interface IReleasePlanMilestone {
id: string;
name: string;
releasePlanDefinitionId: string;
strategies: IReleasePlanMilestoneStrategy[];
}
export interface IReleasePlanMilestoneStrategy extends IFeatureStrategy {
milestoneId: string;
}
export interface IReleasePlanTemplatePayload {

View File

@ -20,7 +20,7 @@ const RolloutSvgIcon: FC = (props) => (
/>
);
export const getFeatureStrategyIcon = (strategyName: string) => {
export const getFeatureStrategyIcon = (strategyName?: string) => {
switch (strategyName) {
case 'default':
return PowerSettingsNewIcon;