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:
parent
26d96a0002
commit
14403d7836
@ -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));
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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);
|
||||
};
|
||||
|
@ -15,7 +15,7 @@ interface IMilestoneListProps {
|
||||
>;
|
||||
openAddStrategyForm: (
|
||||
milestoneId: string,
|
||||
strategy: IReleasePlanMilestoneStrategy,
|
||||
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
|
||||
) => void;
|
||||
errors: { [key: string]: string };
|
||||
clearErrors: () => void;
|
||||
|
@ -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 = ({
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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) =>
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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());
|
||||
};
|
@ -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 {
|
||||
|
@ -20,7 +20,7 @@ const RolloutSvgIcon: FC = (props) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const getFeatureStrategyIcon = (strategyName: string) => {
|
||||
export const getFeatureStrategyIcon = (strategyName?: string) => {
|
||||
switch (strategyName) {
|
||||
case 'default':
|
||||
return PowerSettingsNewIcon;
|
||||
|
Loading…
Reference in New Issue
Block a user