1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00
unleash.unleash/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx

438 lines
16 KiB
TypeScript

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 { useFeatureReleasePlans } from 'hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans';
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.tsx';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { ReleasePlanChangeRequestDialog } from './ChangeRequest/ReleasePlanChangeRequestDialog.tsx';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { Truncator } from 'component/common/Truncator/Truncator';
import { useUiFlag } from 'hooks/useUiFlag';
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
import { DeleteProgressionDialog } from './DeleteProgressionDialog.tsx';
import type { ChangeMilestoneProgressionSchema } from 'openapi';
import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx';
const StyledContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
paddingTop: theme.spacing(0),
background: 'inherit',
display: 'flex',
flexFlow: 'column',
gap: theme.spacing(1),
}));
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
color: theme.palette.text.primary,
}));
const StyledHeaderGroup = styled('hgroup')(({ theme }) => ({
paddingTop: theme.spacing(1.5),
}));
const StyledHeaderTitleLabel = styled('p')(({ theme }) => ({
fontWeight: 'bold',
fontSize: theme.typography.body1.fontSize,
lineHeight: 0.5,
marginBottom: theme.spacing(0.5),
display: 'inline',
}));
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,
}));
const StyledBody = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
}));
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 } = useFeatureReleasePlans(
projectId,
featureName,
environment,
);
const { removeReleasePlanFromFeature, startReleasePlanMilestone } =
useReleasePlansApi();
const { deleteMilestoneProgression } = useMilestoneProgressionsApi();
const { setToastData, setToastApiError } = useToast();
const { trackEvent } = usePlausibleTracker();
const [removeOpen, setRemoveOpen] = useState(false);
const [changeRequestAction, setChangeRequestAction] = useState<
| { type: 'removeReleasePlan'; environmentActive: boolean }
| { type: 'startMilestone'; milestone: IReleasePlanMilestone }
| {
type: 'changeMilestoneProgression';
payload: ChangeMilestoneProgressionSchema;
}
| {
type: 'deleteMilestoneProgression';
sourceMilestoneId: string;
}
| null
>(null);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { addChange } = useChangeRequestApi();
const { data: pendingChangeRequests, refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
// Find progression changes for this feature in pending change requests
const getPendingProgressionChange = (sourceMilestoneId: string) => {
if (!pendingChangeRequests) return null;
for (const changeRequest of pendingChangeRequests) {
if (changeRequest.environment !== environment) continue;
const featureInChangeRequest = changeRequest.features.find(
(featureItem) => featureItem.name === featureName,
);
if (!featureInChangeRequest) continue;
// Look for update or delete progression changes
const progressionChange = featureInChangeRequest.changes.find(
(change: any) =>
(change.action === 'updateMilestoneProgression' &&
(change.payload.sourceMilestoneId ===
sourceMilestoneId ||
change.payload.sourceMilestone ===
sourceMilestoneId)) ||
(change.action === 'deleteMilestoneProgression' &&
(change.payload.sourceMilestoneId ===
sourceMilestoneId ||
change.payload.sourceMilestone ===
sourceMilestoneId)),
);
if (progressionChange) {
return {
action: progressionChange.action,
payload: progressionChange.payload,
changeRequestId: changeRequest.id,
};
}
}
return null;
};
const milestoneProgressionsEnabled = useUiFlag('milestoneProgression');
const [progressionFormOpenIndex, setProgressionFormOpenIndex] = useState<
number | null
>(null);
const [milestoneToDeleteProgression, setMilestoneToDeleteProgression] =
useState<IReleasePlanMilestone | null>(null);
const [isDeletingProgression, setIsDeletingProgression] = useState(false);
const onChangeRequestConfirm = async () => {
if (!changeRequestAction) return;
switch (changeRequestAction.type) {
case 'removeReleasePlan':
await addChange(projectId, environment, {
feature: featureName,
action: 'deleteReleasePlan',
payload: {
planId: plan.id,
},
});
break;
case 'startMilestone':
await addChange(projectId, environment, {
feature: featureName,
action: 'startMilestone',
payload: {
planId: plan.id,
milestoneId: changeRequestAction.milestone.id,
},
});
break;
case 'changeMilestoneProgression':
await addChange(projectId, environment, {
feature: featureName,
action: 'changeMilestoneProgression',
payload: changeRequestAction.payload,
});
break;
case 'deleteMilestoneProgression':
await addChange(projectId, environment, {
feature: featureName,
action: 'deleteMilestoneProgression',
payload: {
sourceMilestone: changeRequestAction.sourceMilestoneId,
},
});
break;
}
await refetchChangeRequests();
setToastData({
type: 'success',
text: 'Added to draft',
});
setChangeRequestAction(null);
setProgressionFormOpenIndex(null);
};
const confirmRemoveReleasePlan = () => {
if (isChangeRequestConfigured(environment)) {
setChangeRequestAction({
type: 'removeReleasePlan',
environmentActive: !environmentIsDisabled,
});
} 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 (isChangeRequestConfigured(environment)) {
setChangeRequestAction({
type: 'startMilestone',
milestone,
});
} 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 handleAddToChangeRequest = (action: {
type: 'changeMilestoneProgression';
payload: ChangeMilestoneProgressionSchema;
}) => {
setChangeRequestAction(action);
};
const handleDeleteProgression = (milestone: IReleasePlanMilestone) => {
if (isChangeRequestConfigured(environment)) {
setChangeRequestAction({
type: 'deleteMilestoneProgression',
sourceMilestoneId: milestone.id,
});
} else {
setMilestoneToDeleteProgression(milestone);
}
};
const handleCloseDeleteDialog = () => {
if (!isDeletingProgression) {
setMilestoneToDeleteProgression(null);
}
};
const onDeleteProgressionConfirm = async () => {
if (!milestoneToDeleteProgression || isDeletingProgression) return;
setIsDeletingProgression(true);
try {
await deleteMilestoneProgression(
projectId,
environment,
featureName,
milestoneToDeleteProgression.id,
);
await refetch();
setMilestoneToDeleteProgression(null);
setToastData({
type: 'success',
text: 'Automation removed successfully',
});
} catch (error: unknown) {
setMilestoneToDeleteProgression(null);
setToastApiError(formatUnknownError(error));
} finally {
setIsDeletingProgression(false);
}
};
const activeIndex = milestones.findIndex(
(milestone) => milestone.id === activeMilestoneId,
);
return (
<StyledContainer>
<StyledHeader>
<StyledHeaderGroup>
<StyledHeaderTitleLabel>
Release plan:{' '}
</StyledHeaderTitleLabel>
<StyledHeaderTitle>{name}</StyledHeaderTitle>
<StyledHeaderDescription>
<Truncator lines={2} title={description}>
{description}
</Truncator>
</StyledHeaderDescription>
</StyledHeaderGroup>
{!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) => (
<ReleasePlanMilestoneItem
key={milestone.id}
milestone={milestone}
index={index}
milestones={milestones}
activeMilestoneId={activeMilestoneId}
activeIndex={activeIndex}
environmentIsDisabled={environmentIsDisabled}
readonly={readonly}
milestoneProgressionsEnabled={
milestoneProgressionsEnabled
}
progressionFormOpenIndex={progressionFormOpenIndex}
onSetProgressionFormOpenIndex={
setProgressionFormOpenIndex
}
onStartMilestone={onStartMilestone}
onDeleteProgression={handleDeleteProgression}
onAddToChangeRequest={handleAddToChangeRequest}
getPendingProgressionChange={
getPendingProgressionChange
}
projectId={projectId}
environment={environment}
featureName={featureName}
onUpdate={refetch}
/>
))}
</StyledBody>
<ReleasePlanRemoveDialog
plan={plan}
open={removeOpen}
setOpen={setRemoveOpen}
onConfirm={onRemoveConfirm}
environmentActive={!environmentIsDisabled}
/>
<ReleasePlanChangeRequestDialog
featureId={featureName}
environmentId={environment}
releasePlan={plan}
action={changeRequestAction}
isOpen={changeRequestAction !== null}
onConfirm={onChangeRequestConfirm}
onClose={() => setChangeRequestAction(null)}
/>
{milestoneToDeleteProgression && (
<DeleteProgressionDialog
open={milestoneToDeleteProgression !== null}
onClose={handleCloseDeleteDialog}
onConfirm={onDeleteProgressionConfirm}
milestoneName={milestoneToDeleteProgression.name}
isDeleting={isDeletingProgression}
/>
)}
</StyledContainer>
);
};