1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-24 20:06:55 +01:00

refactor: composition

This commit is contained in:
FredrikOseberg 2025-10-21 13:12:31 +02:00
parent 244f6fbd43
commit 39c1a963b5
No known key found for this signature in database
GPG Key ID: 282FD8A6D8F9BCF0
6 changed files with 349 additions and 339 deletions

View File

@ -30,6 +30,9 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ
import useToast from 'hooks/useToast';
import type { UpdateMilestoneProgressionSchema } from 'openapi';
import { ReleasePlanProvider } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanContext.tsx';
import { MilestoneAutomationSection } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneAutomationSection.tsx';
import { MilestoneTransitionDisplay } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx';
import type { MilestoneStatus } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
// Indicates that a change is in draft and not yet part of a change request
const PENDING_CHANGE_REQUEST_ID = -1;
@ -373,33 +376,35 @@ const CreateMilestoneProgression: FC<{
const hasProgression = Boolean(milestone.transitionCondition);
const showAutomation = isTargetMilestone && isNotLastMilestone && hasProgression;
console.log('[CreateProgression] Milestone:', milestone.name, {
isTargetMilestone,
isNotLastMilestone,
hasProgression,
showAutomation,
transitionCondition: milestone.transitionCondition,
projectId,
environment: environmentName,
featureName,
});
const readonly = changeRequestState === 'Applied' || changeRequestState === 'Cancelled';
const status: MilestoneStatus = 'not-started'; // In change request view, always not-started
// Build automation section for this milestone
const automationSection = showAutomation && milestone.transitionCondition ? (
<MilestoneAutomationSection status={status}>
<MilestoneTransitionDisplay
intervalMinutes={milestone.transitionCondition.intervalMinutes}
onDelete={() => onDeleteChangeRequestSubmit?.(milestone.id)}
milestoneName={milestone.name}
status={status}
projectId={projectId}
environment={environmentName}
featureName={featureName}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate || (() => {})}
onChangeRequestSubmit={onUpdateChangeRequestSubmit}
hasPendingUpdate={false}
hasPendingDelete={false}
/>
</MilestoneAutomationSection>
) : undefined;
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={changeRequestState === 'Applied' || changeRequestState === 'Cancelled'}
readonly={readonly}
milestone={milestone}
showAutomation={showAutomation}
projectId={projectId}
environment={environmentName}
featureName={featureName}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={onUpdateChangeRequestSubmit}
onDeleteAutomation={
showAutomation && onDeleteChangeRequestSubmit
? () => onDeleteChangeRequestSubmit(milestone.id)
: undefined
}
automationSection={automationSection}
allMilestones={modifiedPlan.milestones}
activeMilestoneId={modifiedPlan.activeMilestoneId}
/>
@ -512,22 +517,35 @@ const UpdateMilestoneProgression: FC<{
const isNotLastMilestone = index < modifiedPlan.milestones.length - 1;
const showAutomation = milestone.id === sourceId && isNotLastMilestone && Boolean(milestone.transitionCondition);
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={changeRequestState === 'Applied' || changeRequestState === 'Cancelled'}
milestone={milestone}
showAutomation={showAutomation}
const readonly = changeRequestState === 'Applied' || changeRequestState === 'Cancelled';
const status: MilestoneStatus = 'not-started';
// Build automation section for this milestone
const automationSection = showAutomation && milestone.transitionCondition ? (
<MilestoneAutomationSection status={status}>
<MilestoneTransitionDisplay
intervalMinutes={milestone.transitionCondition.intervalMinutes}
onDelete={() => onDeleteChangeRequestSubmit?.(milestone.id)}
milestoneName={milestone.name}
status={status}
projectId={projectId}
environment={environmentName}
featureName={featureName}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={onUpdateChangeRequestSubmit}
onDeleteAutomation={
showAutomation && onDeleteChangeRequestSubmit
? () => onDeleteChangeRequestSubmit(milestone.id)
: undefined
}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate || (() => {})}
onChangeRequestSubmit={onUpdateChangeRequestSubmit}
hasPendingUpdate={false}
hasPendingDelete={false}
/>
</MilestoneAutomationSection>
) : undefined;
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={readonly}
milestone={milestone}
automationSection={automationSection}
allMilestones={modifiedPlan.milestones}
activeMilestoneId={modifiedPlan.activeMilestoneId}
/>
@ -712,15 +730,6 @@ const ConsolidatedProgressionChanges: FC<{
? basePlan.milestones.find(baseMilestone => baseMilestone.id === milestone.id)
: null;
// Warn if we can't find the original milestone for a delete change
if (deleteChange && !originalMilestone) {
console.error('[ConsolidatedProgressionChanges] Cannot find original milestone for delete', {
milestoneId: milestone.id,
milestoneName: milestone.name,
basePlanMilestones: basePlan.milestones.map(baseMilestone => ({ id: baseMilestone.id, name: baseMilestone.name }))
});
}
const displayMilestone = deleteChange && originalMilestone ? originalMilestone : milestone;
// Show automation section for any milestone that has a transition condition
@ -728,22 +737,35 @@ const ConsolidatedProgressionChanges: FC<{
const shouldShowAutomationSection = Boolean(displayMilestone.transitionCondition) || Boolean(deleteChange);
const showAutomation = isNotLastMilestone && shouldShowAutomationSection;
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={changeRequestState === 'Applied' || changeRequestState === 'Cancelled'}
milestone={displayMilestone}
showAutomation={showAutomation}
const readonly = changeRequestState === 'Applied' || changeRequestState === 'Cancelled';
const status: MilestoneStatus = 'not-started';
// Build automation section for this milestone
const automationSection = showAutomation && displayMilestone.transitionCondition ? (
<MilestoneAutomationSection status={status}>
<MilestoneTransitionDisplay
intervalMinutes={displayMilestone.transitionCondition.intervalMinutes}
onDelete={() => onDeleteChangeRequestSubmit?.(displayMilestone.id)}
milestoneName={displayMilestone.name}
status={status}
projectId={projectId}
environment={environmentName}
featureName={featureName}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={onUpdateChangeRequestSubmit}
onDeleteAutomation={
showAutomation && onDeleteChangeRequestSubmit
? () => onDeleteChangeRequestSubmit(displayMilestone.id)
: undefined
}
sourceMilestoneId={displayMilestone.id}
onUpdate={onUpdate || (() => {})}
onChangeRequestSubmit={onUpdateChangeRequestSubmit}
hasPendingUpdate={false}
hasPendingDelete={Boolean(deleteChange)}
/>
</MilestoneAutomationSection>
) : undefined;
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={readonly}
milestone={displayMilestone}
automationSection={automationSection}
allMilestones={modifiedPlan.milestones}
activeMilestoneId={modifiedPlan.activeMilestoneId}
/>

View File

@ -13,8 +13,6 @@ import type {
import { useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ReleasePlanRemoveDialog } from './ReleasePlanRemoveDialog.tsx';
import { ReleasePlanMilestone } from './ReleasePlanMilestone/ReleasePlanMilestone.tsx';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
@ -22,7 +20,6 @@ import { ReleasePlanChangeRequestDialog } from './ChangeRequest/ReleasePlanChang
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { Truncator } from 'component/common/Truncator/Truncator';
import { useUiFlag } from 'hooks/useUiFlag';
import { MilestoneProgressionForm } from './MilestoneProgressionForm/MilestoneProgressionForm.tsx';
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
import { DeleteProgressionDialog } from './DeleteProgressionDialog.tsx';
import type {
@ -30,6 +27,7 @@ import type {
UpdateMilestoneProgressionSchema,
} from 'openapi';
import { ReleasePlanProvider } from './ReleasePlanContext.tsx';
import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx';
const StyledContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
@ -76,17 +74,6 @@ const StyledBody = styled('div')(({ theme }) => ({
flexDirection: 'column',
}));
const StyledConnection = styled('div', {
shouldForwardProp: (prop) => prop !== 'isCompleted',
})<{ isCompleted: boolean }>(({ theme, isCompleted }) => ({
width: 2,
height: theme.spacing(2),
backgroundColor: isCompleted
? theme.palette.divider
: theme.palette.primary.main,
marginLeft: theme.spacing(3.25),
}));
interface IReleasePlanProps {
plan: IReleasePlan;
environmentIsDisabled?: boolean;
@ -327,10 +314,6 @@ export const ReleasePlan = ({
await refetch();
};
const handleProgressionCancel = () => {
setProgressionFormOpenIndex(null);
};
const handleProgressionChangeRequestSubmit = (
payload: CreateMilestoneProgressionSchema,
) => {
@ -429,80 +412,31 @@ export const ReleasePlan = ({
)}
</StyledHeader>
<StyledBody>
{milestones.map((milestone, index) => {
const isNotLastMilestone = index < milestones.length - 1;
const isProgressionFormOpen =
progressionFormOpenIndex === index;
const nextMilestoneId = milestones[index + 1]?.id || '';
const handleOpenProgressionForm = () =>
setProgressionFormOpenIndex(index);
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={readonly}
{milestones.map((milestone, index) => (
<ReleasePlanMilestoneItem
key={milestone.id}
milestone={milestone}
status={
milestone.id === activeMilestoneId
? environmentIsDisabled
? 'paused'
: 'active'
: index < activeIndex
? 'completed'
: 'not-started'
}
index={index}
milestones={milestones}
activeMilestoneId={activeMilestoneId}
activeIndex={activeIndex}
environmentIsDisabled={environmentIsDisabled}
readonly={readonly}
milestoneProgressionsEnabled={milestoneProgressionsEnabled}
progressionFormOpenIndex={progressionFormOpenIndex}
onSetProgressionFormOpenIndex={setProgressionFormOpenIndex}
onStartMilestone={onStartMilestone}
showAutomation={
milestoneProgressionsEnabled &&
isNotLastMilestone &&
!readonly
}
onAddAutomation={handleOpenProgressionForm}
onDeleteAutomation={
milestone.transitionCondition
? () =>
handleDeleteProgression(milestone)
: undefined
}
automationForm={
isProgressionFormOpen ? (
<MilestoneProgressionForm
sourceMilestoneId={milestone.id}
targetMilestoneId={nextMilestoneId}
projectId={projectId}
environment={environment}
featureName={featureName}
onSave={handleProgressionSave}
onCancel={handleProgressionCancel}
onChangeRequestSubmit={(payload) =>
handleProgressionChangeRequestSubmit(
payload,
)
}
/>
) : undefined
}
onDeleteProgression={handleDeleteProgression}
onProgressionSave={handleProgressionSave}
onProgressionChangeRequestSubmit={handleProgressionChangeRequestSubmit}
onUpdateProgressionChangeRequestSubmit={handleUpdateProgressionChangeRequestSubmit}
getPendingProgressionChange={getPendingProgressionChange}
projectId={projectId}
environment={environment}
featureName={featureName}
onUpdate={refetch}
onUpdateChangeRequestSubmit={
handleUpdateProgressionChangeRequestSubmit
}
allMilestones={milestones}
activeMilestoneId={activeMilestoneId}
/>
<ConditionallyRender
condition={isNotLastMilestone}
show={
<StyledConnection
isCompleted={index < activeIndex}
/>
}
/>
</div>
);
})}
))}
</StyledBody>
<ReleasePlanRemoveDialog
plan={plan}

View File

@ -1,10 +1,5 @@
import Add from '@mui/icons-material/Add';
import { Button, styled } from '@mui/material';
import { styled } from '@mui/material';
import type { MilestoneStatus } from './ReleasePlanMilestoneStatus.tsx';
import { MilestoneTransitionDisplay } from './MilestoneTransitionDisplay.tsx';
import type { UpdateMilestoneProgressionSchema } from 'openapi';
import { Badge } from 'component/common/Badge/Badge';
import { useReleasePlanContext } from '../ReleasePlanContext.tsx';
const StyledAutomationContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'status',
@ -26,120 +21,18 @@ const StyledAutomationContainer = styled('div', {
},
}));
const StyledAddAutomationButton = styled(Button)(({ theme }) => ({
textTransform: 'none',
fontWeight: theme.typography.fontWeightBold,
fontSize: theme.typography.body2.fontSize,
padding: 0,
minWidth: 'auto',
gap: theme.spacing(1),
'&:hover': {
backgroundColor: 'transparent',
},
'& .MuiButton-startIcon': {
margin: 0,
width: 20,
height: 20,
border: `1px solid ${theme.palette.primary.main}`,
backgroundColor: theme.palette.background.elevation2,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
fontSize: 14,
color: theme.palette.primary.main,
},
},
}));
const StyledAddAutomationContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
interface IMilestoneAutomationSectionProps {
showAutomation?: boolean;
status?: MilestoneStatus;
onAddAutomation?: () => void;
onDeleteAutomation?: () => void;
automationForm?: React.ReactNode;
transitionCondition?: {
intervalMinutes: number;
} | null;
milestoneName: string;
projectId: string;
environment: string;
featureName: string;
sourceMilestoneId: string;
onUpdate: () => void;
onUpdateChangeRequestSubmit?: (
sourceMilestoneId: string,
payload: UpdateMilestoneProgressionSchema,
) => void;
children: React.ReactNode;
}
export const MilestoneAutomationSection = ({
showAutomation,
status,
onAddAutomation,
onDeleteAutomation,
automationForm,
transitionCondition,
milestoneName,
projectId,
environment,
featureName,
sourceMilestoneId,
onUpdate,
onUpdateChangeRequestSubmit,
children,
}: IMilestoneAutomationSectionProps) => {
const { getPendingProgressionChange } = useReleasePlanContext();
const pendingProgressionChange = getPendingProgressionChange(sourceMilestoneId);
const hasPendingCreate = pendingProgressionChange?.action === 'createMilestoneProgression';
// For pending create changes, use the transition condition from the pending change
const effectiveTransitionCondition = hasPendingCreate && pendingProgressionChange?.payload?.transitionCondition
? pendingProgressionChange.payload.transitionCondition
: transitionCondition;
if (!showAutomation) return null;
return (
<StyledAutomationContainer status={status}>
{automationForm ? (
automationForm
) : effectiveTransitionCondition ? (
<MilestoneTransitionDisplay
intervalMinutes={effectiveTransitionCondition.intervalMinutes}
onDelete={onDeleteAutomation!}
milestoneName={milestoneName}
status={status}
projectId={projectId}
environment={environment}
featureName={featureName}
sourceMilestoneId={sourceMilestoneId}
onUpdate={onUpdate}
onChangeRequestSubmit={onUpdateChangeRequestSubmit}
/>
) : (
<StyledAddAutomationContainer>
<StyledAddAutomationButton
onClick={onAddAutomation}
color='primary'
startIcon={<Add />}
>
Add automation
</StyledAddAutomationButton>
{hasPendingCreate && (
<Badge color='warning'>
Modified in draft
</Badge>
)}
</StyledAddAutomationContainer>
)}
{children}
</StyledAutomationContainer>
);
};

View File

@ -14,7 +14,6 @@ import {
} from '../hooks/useMilestoneProgressionForm.js';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import type { UpdateMilestoneProgressionSchema } from 'openapi';
import { useReleasePlanContext } from '../ReleasePlanContext.tsx';
const StyledDisplayContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -75,6 +74,8 @@ interface IMilestoneTransitionDisplayProps {
sourceMilestoneId: string,
payload: UpdateMilestoneProgressionSchema,
) => void;
hasPendingUpdate?: boolean;
hasPendingDelete?: boolean;
}
export const MilestoneTransitionDisplay = ({
@ -88,12 +89,12 @@ export const MilestoneTransitionDisplay = ({
sourceMilestoneId,
onUpdate,
onChangeRequestSubmit,
hasPendingUpdate = false,
hasPendingDelete = false,
}: IMilestoneTransitionDisplayProps) => {
const { updateMilestoneProgression } = useMilestoneProgressionsApi();
const { setToastData, setToastApiError } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { getPendingProgressionChange } = useReleasePlanContext();
const pendingProgressionChange = getPendingProgressionChange(sourceMilestoneId);
const initial = getTimeValueAndUnitFromMinutes(intervalMinutes);
const form = useMilestoneProgressionForm(
@ -109,11 +110,6 @@ export const MilestoneTransitionDisplay = ({
const currentIntervalMinutes = form.getIntervalMinutes();
const hasChanged = currentIntervalMinutes !== intervalMinutes;
// Check if there's a pending change request for this progression
const hasPendingUpdate =
pendingProgressionChange?.action === 'updateMilestoneProgression';
const hasPendingDelete =
pendingProgressionChange?.action === 'deleteMilestoneProgression';
const showDraftBadge = hasPendingUpdate || hasPendingDelete;
const handleSave = async () => {

View File

@ -17,9 +17,7 @@ import { StrategySeparator } from 'component/common/StrategySeparator/StrategySe
import { StrategyItem } from '../../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx';
import { StrategyList } from 'component/common/StrategyList/StrategyList';
import { StrategyListItem } from 'component/common/StrategyList/StrategyListItem';
import { MilestoneAutomationSection } from './MilestoneAutomationSection.tsx';
import { formatDateYMDHMS } from 'utils/formatDate';
import type { UpdateMilestoneProgressionSchema } from 'openapi';
const StyledAccordion = styled(Accordion, {
shouldForwardProp: (prop) => prop !== 'status' && prop !== 'hasAutomation',
@ -100,18 +98,7 @@ interface IReleasePlanMilestoneProps {
status?: MilestoneStatus;
onStartMilestone?: (milestone: IReleasePlanMilestone) => void;
readonly?: boolean;
showAutomation?: boolean;
onAddAutomation?: () => void;
onDeleteAutomation?: () => void;
automationForm?: React.ReactNode;
projectId?: string;
environment?: string;
featureName?: string;
onUpdate?: () => void;
onUpdateChangeRequestSubmit?: (
sourceMilestoneId: string,
payload: UpdateMilestoneProgressionSchema,
) => void;
automationSection?: React.ReactNode;
allMilestones: IReleasePlanMilestone[];
activeMilestoneId?: string;
}
@ -121,24 +108,17 @@ export const ReleasePlanMilestone = ({
status = 'not-started',
onStartMilestone,
readonly,
showAutomation,
onAddAutomation,
onDeleteAutomation,
automationForm,
projectId,
environment,
featureName,
onUpdate,
onUpdateChangeRequestSubmit,
automationSection,
allMilestones,
activeMilestoneId,
}: IReleasePlanMilestoneProps) => {
const [expanded, setExpanded] = useState(false);
const hasAutomation = Boolean(automationSection);
if (!milestone.strategies.length) {
return (
<StyledMilestoneContainer>
<StyledAccordion status={status} hasAutomation={showAutomation}>
<StyledAccordion status={status} hasAutomation={hasAutomation}>
<StyledAccordionSummary>
<StyledTitleContainer>
<StyledTitle status={status}>
@ -181,29 +161,7 @@ export const ReleasePlanMilestone = ({
</StyledSecondaryLabel>
</StyledAccordionSummary>
</StyledAccordion>
{showAutomation &&
projectId &&
environment &&
featureName &&
onUpdate && (
<MilestoneAutomationSection
showAutomation={showAutomation}
status={status}
onAddAutomation={onAddAutomation}
onDeleteAutomation={onDeleteAutomation}
automationForm={automationForm}
transitionCondition={milestone.transitionCondition}
milestoneName={milestone.name}
projectId={projectId}
environment={environment}
featureName={featureName}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={
onUpdateChangeRequestSubmit
}
/>
)}
{automationSection}
</StyledMilestoneContainer>
);
}
@ -212,7 +170,7 @@ export const ReleasePlanMilestone = ({
<StyledMilestoneContainer>
<StyledAccordion
status={status}
hasAutomation={showAutomation}
hasAutomation={hasAutomation}
onChange={(evt, expanded) => setExpanded(expanded)}
>
<StyledAccordionSummary expandIcon={<ExpandMore />}>
@ -274,29 +232,7 @@ export const ReleasePlanMilestone = ({
</StrategyList>
</StyledAccordionDetails>
</StyledAccordion>
{showAutomation &&
projectId &&
environment &&
featureName &&
onUpdate && (
<MilestoneAutomationSection
showAutomation={showAutomation}
status={status}
onAddAutomation={onAddAutomation}
onDeleteAutomation={onDeleteAutomation}
automationForm={automationForm}
transitionCondition={milestone.transitionCondition}
milestoneName={milestone.name}
projectId={projectId}
environment={environment}
featureName={featureName}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={
onUpdateChangeRequestSubmit
}
/>
)}
{automationSection}
</StyledMilestoneContainer>
);
};

View File

@ -0,0 +1,229 @@
import Add from '@mui/icons-material/Add';
import { Button, styled } from '@mui/material';
import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
import type {
CreateMilestoneProgressionSchema,
UpdateMilestoneProgressionSchema,
} from 'openapi';
import { MilestoneAutomationSection } from '../ReleasePlanMilestone/MilestoneAutomationSection.tsx';
import { MilestoneTransitionDisplay } from '../ReleasePlanMilestone/MilestoneTransitionDisplay.tsx';
import { ReleasePlanMilestone } from '../ReleasePlanMilestone/ReleasePlanMilestone.tsx';
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
import { MilestoneProgressionForm } from '../MilestoneProgressionForm/MilestoneProgressionForm.tsx';
const StyledConnection = styled('div', {
shouldForwardProp: (prop) => prop !== 'isCompleted',
})<{ isCompleted: boolean }>(({ theme, isCompleted }) => ({
width: 2,
height: theme.spacing(2),
backgroundColor: isCompleted
? theme.palette.divider
: theme.palette.primary.main,
marginLeft: theme.spacing(3.25),
}));
const StyledAddAutomationButton = styled(Button)(({ theme }) => ({
textTransform: 'none',
fontWeight: theme.typography.fontWeightBold,
fontSize: theme.typography.body2.fontSize,
padding: 0,
minWidth: 'auto',
gap: theme.spacing(1),
'&:hover': {
backgroundColor: 'transparent',
},
'& .MuiButton-startIcon': {
margin: 0,
width: 20,
height: 20,
border: `1px solid ${theme.palette.primary.main}`,
backgroundColor: theme.palette.background.elevation2,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
fontSize: 14,
color: theme.palette.primary.main,
},
},
}));
const StyledAddAutomationContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
interface PendingProgressionChange {
action: string;
payload: any;
changeRequestId: number;
}
interface IReleasePlanMilestoneItemProps {
milestone: IReleasePlanMilestone;
index: number;
milestones: IReleasePlanMilestone[];
activeMilestoneId?: string;
activeIndex: number;
environmentIsDisabled?: boolean;
readonly?: boolean;
// Automation-related
milestoneProgressionsEnabled: boolean;
progressionFormOpenIndex: number | null;
onSetProgressionFormOpenIndex: (index: number | null) => void;
// API callbacks
onStartMilestone?: (milestone: IReleasePlanMilestone) => void;
onDeleteProgression: (milestone: IReleasePlanMilestone) => void;
onProgressionSave: () => Promise<void>;
onProgressionChangeRequestSubmit: (
payload: CreateMilestoneProgressionSchema,
) => void;
onUpdateProgressionChangeRequestSubmit: (
sourceMilestoneId: string,
payload: UpdateMilestoneProgressionSchema,
) => void;
// Context
getPendingProgressionChange: (
sourceMilestoneId: string,
) => PendingProgressionChange | null;
// IDs
projectId: string;
environment: string;
featureName: string;
onUpdate: () => void;
}
export const ReleasePlanMilestoneItem = ({
milestone,
index,
milestones,
activeMilestoneId,
activeIndex,
environmentIsDisabled,
readonly,
milestoneProgressionsEnabled,
progressionFormOpenIndex,
onSetProgressionFormOpenIndex,
onStartMilestone,
onDeleteProgression,
onProgressionSave,
onProgressionChangeRequestSubmit,
onUpdateProgressionChangeRequestSubmit,
getPendingProgressionChange,
projectId,
environment,
featureName,
onUpdate,
}: IReleasePlanMilestoneItemProps) => {
const isNotLastMilestone = index < milestones.length - 1;
const isProgressionFormOpen = progressionFormOpenIndex === index;
const nextMilestoneId = milestones[index + 1]?.id || '';
const handleOpenProgressionForm = () => onSetProgressionFormOpenIndex(index);
const handleCloseProgressionForm = () => onSetProgressionFormOpenIndex(null);
const status: MilestoneStatus =
milestone.id === activeMilestoneId
? environmentIsDisabled
? 'paused'
: 'active'
: index < activeIndex
? 'completed'
: 'not-started';
// Calculate pending progression change for this milestone
const pendingProgressionChange = getPendingProgressionChange(milestone.id);
const hasPendingCreate =
pendingProgressionChange?.action === 'createMilestoneProgression';
const hasPendingUpdate =
pendingProgressionChange?.action === 'updateMilestoneProgression';
const hasPendingDelete =
pendingProgressionChange?.action === 'deleteMilestoneProgression';
// Determine effective transition condition (use pending create if exists)
let effectiveTransitionCondition = milestone.transitionCondition;
if (
pendingProgressionChange?.action === 'createMilestoneProgression' &&
'transitionCondition' in pendingProgressionChange.payload &&
pendingProgressionChange.payload.transitionCondition
) {
effectiveTransitionCondition =
pendingProgressionChange.payload.transitionCondition;
}
// Build automation section
const showAutomation =
milestoneProgressionsEnabled && isNotLastMilestone && !readonly;
const automationSection = showAutomation ? (
<MilestoneAutomationSection status={status}>
{isProgressionFormOpen ? (
<MilestoneProgressionForm
sourceMilestoneId={milestone.id}
targetMilestoneId={nextMilestoneId}
projectId={projectId}
environment={environment}
featureName={featureName}
onSave={onProgressionSave}
onCancel={handleCloseProgressionForm}
onChangeRequestSubmit={(payload) =>
onProgressionChangeRequestSubmit(payload)
}
/>
) : effectiveTransitionCondition ? (
<MilestoneTransitionDisplay
intervalMinutes={effectiveTransitionCondition.intervalMinutes}
onDelete={() => onDeleteProgression(milestone)}
milestoneName={milestone.name}
status={status}
projectId={projectId}
environment={environment}
featureName={featureName}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate}
onChangeRequestSubmit={onUpdateProgressionChangeRequestSubmit}
hasPendingUpdate={hasPendingUpdate}
hasPendingDelete={hasPendingDelete}
/>
) : (
<StyledAddAutomationContainer>
<StyledAddAutomationButton
onClick={handleOpenProgressionForm}
color='primary'
startIcon={<Add />}
>
Add automation
</StyledAddAutomationButton>
{hasPendingCreate && (
<Badge color='warning'>Modified in draft</Badge>
)}
</StyledAddAutomationContainer>
)}
</MilestoneAutomationSection>
) : undefined;
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={readonly}
milestone={milestone}
status={status}
onStartMilestone={onStartMilestone}
automationSection={automationSection}
allMilestones={milestones}
activeMilestoneId={activeMilestoneId}
/>
<ConditionallyRender
condition={isNotLastMilestone}
show={<StyledConnection isCompleted={index < activeIndex} />}
/>
</div>
);
};