1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-28 00:17:12 +01:00

chore: handle release plans in new strategy list (#9380)

Splits the release plan component into a Legacy component and a new one
with the initial changes for the new strategy list view.

Here's what it looks like:

![image](https://github.com/user-attachments/assets/ecca20d5-1c29-42a9-93f4-61d158ba5a76)

Notice that the background color stops a little early (before the OR
token). I'll handle that in a follow-up because the changes also impact
how the rest of the env accordion body is rendered.
This commit is contained in:
Thomas Heartman 2025-02-27 11:16:24 +01:00 committed by GitHub
parent e29eb51f3c
commit 359b7cc4c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 366 additions and 44 deletions

View File

@ -22,10 +22,10 @@ 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';
import { StrategyDraggableItem as NewStrategyDraggableItem } from './StrategyDraggableItem/StrategyDraggableItem';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { ReleasePlan } from '../../../ReleasePlan/ReleasePlan';
interface IEnvironmentAccordionBodyProps {
isDisabled: boolean;
@ -66,6 +66,10 @@ const StyledStrategyList = styled('ol')({
margin: 0,
});
const StyledReleasePlanList = styled(StyledStrategyList)(({ theme }) => ({
background: theme.palette.background.elevation2,
}));
export const EnvironmentAccordionBody = ({
featureEnvironment,
isDisabled,
@ -234,29 +238,20 @@ export const EnvironmentAccordionBody = ({
condition={releasePlans.length > 0 || strategies.length > 0}
show={
<>
{releasePlans.map((plan) => (
<ReleasePlan
key={plan.id}
plan={plan}
environmentIsDisabled={isDisabled}
/>
))}
<ConditionallyRender
condition={
releasePlans.length > 0 &&
strategies.length > 0
}
show={
<>
<SectionSeparator>
<StyledBadge>OR</StyledBadge>
</SectionSeparator>
<AdditionalStrategiesDiv>
Additional strategies
</AdditionalStrategiesDiv>
</>
}
/>
<StyledReleasePlanList>
{releasePlans.map((plan) => (
<li key={plan.id}>
<ReleasePlan
plan={plan}
environmentIsDisabled={isDisabled}
/>
</li>
))}
</StyledReleasePlanList>
{releasePlans.length > 0 &&
strategies.length > 0 ? (
<StrategySeparator text='OR' />
) : null}
<ConditionallyRender
condition={
strategies.length < 50 ||

View File

@ -23,7 +23,7 @@ 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 { ReleasePlan } from '../../../ReleasePlan/LegacyReleasePlan';
import { Badge } from 'component/common/Badge/Badge';
import { SectionSeparator } from '../SectionSeparator/SectionSeparator';

View File

@ -0,0 +1,324 @@
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/ReleasePlanMilestone';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useUiFlag } from 'hooks/useUiFlag';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { RemoveReleasePlanChangeRequestDialog } from './ChangeRequest/RemoveReleasePlanChangeRequestDialog';
import { StartMilestoneChangeRequestDialog } from './ChangeRequest/StartMilestoneChangeRequestDialog';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { Truncator } from 'component/common/Truncator/Truncator';
const StyledContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'readonly',
})<{ readonly?: boolean }>(({ theme, readonly }) => ({
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusMedium,
'& + &': {
marginTop: theme.spacing(2),
},
background: readonly
? theme.palette.background.elevation1
: theme.palette.background.paper,
}));
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
color: 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,
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;
environmentIsDisabled?: boolean;
readonly?: boolean;
}
export const ReleasePlan = ({
plan,
environmentIsDisabled,
readonly,
}: 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 { trackEvent } = usePlausibleTracker();
const [removeOpen, setRemoveOpen] = useState(false);
const [changeRequestDialogRemoveOpen, setChangeRequestDialogRemoveOpen] =
useState(false);
const [
changeRequestDialogStartMilestoneOpen,
setChangeRequestDialogStartMilestoneOpen,
] = useState(false);
const [
milestoneForChangeRequestDialog,
setMilestoneForChangeRequestDialog,
] = useState<IReleasePlanMilestone>();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const releasePlanChangeRequestsEnabled = useUiFlag(
'releasePlanChangeRequests',
);
const onAddRemovePlanChangesConfirm = async () => {
await addChange(projectId, environment, {
feature: featureName,
action: 'deleteReleasePlan',
payload: {
planId: plan.id,
},
});
await refetchChangeRequests();
setToastData({
type: 'success',
text: 'Added to draft',
});
setChangeRequestDialogRemoveOpen(false);
};
const onAddStartMilestoneChangesConfirm = async () => {
await addChange(projectId, environment, {
feature: featureName,
action: 'startMilestone',
payload: {
planId: plan.id,
milestoneId: milestoneForChangeRequestDialog?.id,
},
});
await refetchChangeRequests();
setToastData({
type: 'success',
text: 'Added to draft',
});
setChangeRequestDialogStartMilestoneOpen(false);
};
const confirmRemoveReleasePlan = () => {
if (
releasePlanChangeRequestsEnabled &&
isChangeRequestConfigured(environment)
) {
setChangeRequestDialogRemoveOpen(true);
} else {
setRemoveOpen(true);
}
trackEvent('release-management', {
props: {
eventType: 'remove-plan',
plan: name,
},
});
};
const onRemoveConfirm = async () => {
try {
await removeReleasePlanFromFeature(
projectId,
featureName,
environment,
id,
);
setToastData({
text: `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) => {
if (
releasePlanChangeRequestsEnabled &&
isChangeRequestConfigured(environment)
) {
setMilestoneForChangeRequestDialog(milestone);
setChangeRequestDialogStartMilestoneOpen(true);
} else {
try {
await startReleasePlanMilestone(
projectId,
featureName,
environment,
id,
milestone.id,
);
setToastData({
text: `Milestone "${milestone.name}" has started`,
type: 'success',
});
refetch();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
trackEvent('release-management', {
props: {
eventType: 'start-milestone',
plan: name,
milestone: milestone.name,
},
});
};
const activeIndex = milestones.findIndex(
(milestone) => milestone.id === activeMilestoneId,
);
return (
<StyledContainer readonly={readonly}>
<StyledHeader>
<StyledHeaderTitleContainer>
<StyledHeaderTitleLabel>
Release plan
</StyledHeaderTitleLabel>
<span>{name}</span>
<StyledHeaderDescription>
<Truncator lines={2} title={description}>
{description}
</Truncator>
</StyledHeaderDescription>
</StyledHeaderTitleContainer>
{!readonly && (
<PermissionIconButton
onClick={confirmRemoveReleasePlan}
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
readonly={readonly}
milestone={milestone}
status={
milestone.id === activeMilestoneId
? environmentIsDisabled
? 'paused'
: '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}
environmentActive={!environmentIsDisabled}
/>
<RemoveReleasePlanChangeRequestDialog
environmentId={environment}
featureId={featureName}
isOpen={changeRequestDialogRemoveOpen}
onConfirm={onAddRemovePlanChangesConfirm}
onClosing={() => setChangeRequestDialogRemoveOpen(false)}
releasePlan={plan}
environmentActive={!environmentIsDisabled}
/>
<StartMilestoneChangeRequestDialog
environmentId={environment}
featureId={featureName}
isOpen={changeRequestDialogStartMilestoneOpen}
onConfirm={onAddStartMilestoneChangesConfirm}
onClosing={() => {
setMilestoneForChangeRequestDialog(undefined);
setChangeRequestDialogStartMilestoneOpen(false);
}}
releasePlan={plan}
milestone={milestoneForChangeRequestDialog}
/>
</StyledContainer>
);
};

View File

@ -28,13 +28,10 @@ const StyledContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'readonly',
})<{ readonly?: boolean }>(({ theme, readonly }) => ({
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusMedium,
'& + &': {
marginTop: theme.spacing(2),
},
background: readonly
? theme.palette.background.elevation1
: theme.palette.background.paper,
background: 'inherit',
}));
const StyledHeader = styled('div')(({ theme }) => ({
@ -43,22 +40,28 @@ const StyledHeader = styled('div')(({ theme }) => ({
color: theme.palette.text.primary,
}));
const StyledHeaderTitleContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: theme.spacing(1),
const StyledHeaderGroup = styled('hgroup')(({ theme }) => ({
paddingTop: theme.spacing(1.5),
}));
const StyledHeaderTitleLabel = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
const StyledHeaderTitleLabel = styled('p')(({ theme }) => ({
fontWeight: 'bold',
fontSize: theme.typography.body1.fontSize,
lineHeight: 0.5,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(0.5),
display: 'inline',
}));
const StyledHeaderDescription = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
const StyledHeaderTitle = styled('h3')(({ theme }) => ({
display: 'inline',
margin: 0,
fontWeight: 'normal',
fontSize: theme.typography.body1.fontSize,
}));
const StyledHeaderDescription = styled('p')(({ theme }) => ({
marginTop: theme.spacing(1),
fontSize: theme.typography.body2.fontSize,
color: theme.palette.text.secondary,
}));
@ -242,17 +245,17 @@ export const ReleasePlan = ({
return (
<StyledContainer readonly={readonly}>
<StyledHeader>
<StyledHeaderTitleContainer>
<StyledHeaderGroup>
<StyledHeaderTitleLabel>
Release plan
Release plan:{' '}
</StyledHeaderTitleLabel>
<span>{name}</span>
<StyledHeaderTitle>{name}</StyledHeaderTitle>
<StyledHeaderDescription>
<Truncator lines={2} title={description}>
{description}
</Truncator>
</StyledHeaderDescription>
</StyledHeaderTitleContainer>
</StyledHeaderGroup>
{!readonly && (
<PermissionIconButton
onClick={confirmRemoveReleasePlan}