mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: change request progression view (#10835)
This commit is contained in:
		
							parent
							
								
									b9d81e5f59
								
							
						
					
					
						commit
						866441a1b6
					
				@ -77,6 +77,7 @@ export const ChangeRequest: VFC<IChangeRequestProps> = ({
 | 
			
		||||
                            change={change}
 | 
			
		||||
                            feature={feature}
 | 
			
		||||
                            onNavigate={onNavigate}
 | 
			
		||||
                            onRefetch={onRefetch}
 | 
			
		||||
                        />
 | 
			
		||||
                    ))}
 | 
			
		||||
                    {feature.defaultChange ? (
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,187 @@
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import { styled } from '@mui/material';
 | 
			
		||||
import type {
 | 
			
		||||
    ChangeRequestState,
 | 
			
		||||
    IChangeRequestCreateMilestoneProgression,
 | 
			
		||||
    IChangeRequestUpdateMilestoneProgression,
 | 
			
		||||
    IChangeRequestDeleteMilestoneProgression,
 | 
			
		||||
    IChangeRequestFeature,
 | 
			
		||||
} from 'component/changeRequest/changeRequest.types';
 | 
			
		||||
import type { IReleasePlan } from 'interfaces/releasePlans';
 | 
			
		||||
import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx';
 | 
			
		||||
import {
 | 
			
		||||
    Added,
 | 
			
		||||
    ChangeItemInfo,
 | 
			
		||||
    ChangeItemWrapper,
 | 
			
		||||
    Deleted,
 | 
			
		||||
} from './Change.styles.tsx';
 | 
			
		||||
import type { UpdateMilestoneProgressionSchema } from 'openapi';
 | 
			
		||||
import { MilestoneListRenderer } from './MilestoneListRenderer.tsx';
 | 
			
		||||
import { applyProgressionChanges } from './applyProgressionChanges.js';
 | 
			
		||||
import { EventDiff } from 'component/events/EventDiff/EventDiff';
 | 
			
		||||
 | 
			
		||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexFlow: 'column',
 | 
			
		||||
    gap: theme.spacing(1),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
type ProgressionChange =
 | 
			
		||||
    | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
    | IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
    | IChangeRequestDeleteMilestoneProgression;
 | 
			
		||||
 | 
			
		||||
const getFirstChangeWithSnapshot = (
 | 
			
		||||
    progressionChanges: ProgressionChange[],
 | 
			
		||||
) => {
 | 
			
		||||
    return (
 | 
			
		||||
        progressionChanges.find(
 | 
			
		||||
            (change) =>
 | 
			
		||||
                change.payload?.snapshot &&
 | 
			
		||||
                (change.action === 'createMilestoneProgression' ||
 | 
			
		||||
                    change.action === 'updateMilestoneProgression'),
 | 
			
		||||
        ) || progressionChanges.find((change) => change.payload?.snapshot)
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getMilestonesWithAutomation = (
 | 
			
		||||
    progressionChanges: ProgressionChange[],
 | 
			
		||||
): Set<string> => {
 | 
			
		||||
    return new Set(
 | 
			
		||||
        progressionChanges
 | 
			
		||||
            .filter(
 | 
			
		||||
                (change) =>
 | 
			
		||||
                    change.action === 'createMilestoneProgression' ||
 | 
			
		||||
                    change.action === 'updateMilestoneProgression',
 | 
			
		||||
            )
 | 
			
		||||
            .map((change) => change.payload.sourceMilestone)
 | 
			
		||||
            .filter((id): id is string => Boolean(id)),
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getMilestonesWithDeletedAutomation = (
 | 
			
		||||
    progressionChanges: ProgressionChange[],
 | 
			
		||||
): Set<string> => {
 | 
			
		||||
    return new Set(
 | 
			
		||||
        progressionChanges
 | 
			
		||||
            .filter((change) => change.action === 'deleteMilestoneProgression')
 | 
			
		||||
            .map((change) => change.payload.sourceMilestone)
 | 
			
		||||
            .filter((id): id is string => Boolean(id)),
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getChangeDescriptions = (
 | 
			
		||||
    progressionChanges: ProgressionChange[],
 | 
			
		||||
    basePlan: IReleasePlan,
 | 
			
		||||
): string[] => {
 | 
			
		||||
    return progressionChanges.map((change) => {
 | 
			
		||||
        const sourceId = change.payload.sourceMilestone;
 | 
			
		||||
        const sourceName =
 | 
			
		||||
            basePlan.milestones.find((milestone) => milestone.id === sourceId)
 | 
			
		||||
                ?.name || sourceId;
 | 
			
		||||
        const action =
 | 
			
		||||
            change.action === 'createMilestoneProgression'
 | 
			
		||||
                ? 'Adding'
 | 
			
		||||
                : change.action === 'deleteMilestoneProgression'
 | 
			
		||||
                  ? 'Deleting'
 | 
			
		||||
                  : 'Updating';
 | 
			
		||||
        return `${action} automation for ${sourceName}`;
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ConsolidatedProgressionChanges: FC<{
 | 
			
		||||
    feature: IChangeRequestFeature;
 | 
			
		||||
    currentReleasePlan?: IReleasePlan;
 | 
			
		||||
    changeRequestState: ChangeRequestState;
 | 
			
		||||
    onUpdateChangeRequestSubmit?: (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => Promise<void>;
 | 
			
		||||
    onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => Promise<void>;
 | 
			
		||||
}> = ({
 | 
			
		||||
    feature,
 | 
			
		||||
    currentReleasePlan,
 | 
			
		||||
    changeRequestState,
 | 
			
		||||
    onUpdateChangeRequestSubmit,
 | 
			
		||||
    onDeleteChangeRequestSubmit,
 | 
			
		||||
}) => {
 | 
			
		||||
    // Get all progression changes for this feature
 | 
			
		||||
    const progressionChanges = feature.changes.filter(
 | 
			
		||||
        (
 | 
			
		||||
            change,
 | 
			
		||||
        ): change is
 | 
			
		||||
            | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
            | IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
            | IChangeRequestDeleteMilestoneProgression =>
 | 
			
		||||
            change.action === 'createMilestoneProgression' ||
 | 
			
		||||
            change.action === 'updateMilestoneProgression' ||
 | 
			
		||||
            change.action === 'deleteMilestoneProgression',
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (progressionChanges.length === 0) return null;
 | 
			
		||||
 | 
			
		||||
    const firstChangeWithSnapshot =
 | 
			
		||||
        getFirstChangeWithSnapshot(progressionChanges);
 | 
			
		||||
    const basePlan =
 | 
			
		||||
        firstChangeWithSnapshot?.payload?.snapshot || currentReleasePlan;
 | 
			
		||||
 | 
			
		||||
    if (!basePlan) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const modifiedPlan = applyProgressionChanges(basePlan, progressionChanges);
 | 
			
		||||
    const milestonesWithAutomation =
 | 
			
		||||
        getMilestonesWithAutomation(progressionChanges);
 | 
			
		||||
    const milestonesWithDeletedAutomation =
 | 
			
		||||
        getMilestonesWithDeletedAutomation(progressionChanges);
 | 
			
		||||
    const changeDescriptions = getChangeDescriptions(
 | 
			
		||||
        progressionChanges,
 | 
			
		||||
        basePlan,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledTabs>
 | 
			
		||||
            <ChangeItemWrapper>
 | 
			
		||||
                <ChangeItemInfo>
 | 
			
		||||
                    {progressionChanges.map((change, index) => {
 | 
			
		||||
                        const Component =
 | 
			
		||||
                            change.action === 'deleteMilestoneProgression'
 | 
			
		||||
                                ? Deleted
 | 
			
		||||
                                : Added;
 | 
			
		||||
                        return (
 | 
			
		||||
                            <Component key={index}>
 | 
			
		||||
                                {changeDescriptions[index]}
 | 
			
		||||
                            </Component>
 | 
			
		||||
                        );
 | 
			
		||||
                    })}
 | 
			
		||||
                </ChangeItemInfo>
 | 
			
		||||
                <div>
 | 
			
		||||
                    <TabList>
 | 
			
		||||
                        <Tab>View change</Tab>
 | 
			
		||||
                        <Tab>View diff</Tab>
 | 
			
		||||
                    </TabList>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ChangeItemWrapper>
 | 
			
		||||
            <TabPanel>
 | 
			
		||||
                <MilestoneListRenderer
 | 
			
		||||
                    plan={modifiedPlan}
 | 
			
		||||
                    changeRequestState={changeRequestState}
 | 
			
		||||
                    milestonesWithAutomation={milestonesWithAutomation}
 | 
			
		||||
                    milestonesWithDeletedAutomation={
 | 
			
		||||
                        milestonesWithDeletedAutomation
 | 
			
		||||
                    }
 | 
			
		||||
                    onUpdateAutomation={onUpdateChangeRequestSubmit}
 | 
			
		||||
                    onDeleteAutomation={onDeleteChangeRequestSubmit}
 | 
			
		||||
                />
 | 
			
		||||
            </TabPanel>
 | 
			
		||||
            <TabPanel variant='diff'>
 | 
			
		||||
                <EventDiff
 | 
			
		||||
                    entry={{
 | 
			
		||||
                        preData: basePlan,
 | 
			
		||||
                        data: modifiedPlan,
 | 
			
		||||
                    }}
 | 
			
		||||
                />
 | 
			
		||||
            </TabPanel>
 | 
			
		||||
        </StyledTabs>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -80,6 +80,7 @@ export const FeatureChange: FC<{
 | 
			
		||||
    feature: IChangeRequestFeature;
 | 
			
		||||
    onNavigate?: () => void;
 | 
			
		||||
    isDefaultChange?: boolean;
 | 
			
		||||
    onRefetch?: () => void;
 | 
			
		||||
}> = ({
 | 
			
		||||
    index,
 | 
			
		||||
    change,
 | 
			
		||||
@ -88,6 +89,7 @@ export const FeatureChange: FC<{
 | 
			
		||||
    actions,
 | 
			
		||||
    onNavigate,
 | 
			
		||||
    isDefaultChange,
 | 
			
		||||
    onRefetch,
 | 
			
		||||
}) => {
 | 
			
		||||
    const lastIndex = feature.defaultChange
 | 
			
		||||
        ? feature.changes.length + 1
 | 
			
		||||
@ -204,7 +206,10 @@ export const FeatureChange: FC<{
 | 
			
		||||
                )}
 | 
			
		||||
                {(change.action === 'addReleasePlan' ||
 | 
			
		||||
                    change.action === 'deleteReleasePlan' ||
 | 
			
		||||
                    change.action === 'startMilestone') && (
 | 
			
		||||
                    change.action === 'startMilestone' ||
 | 
			
		||||
                    change.action === 'createMilestoneProgression' ||
 | 
			
		||||
                    change.action === 'updateMilestoneProgression' ||
 | 
			
		||||
                    change.action === 'deleteMilestoneProgression') && (
 | 
			
		||||
                    <ReleasePlanChange
 | 
			
		||||
                        actions={actions}
 | 
			
		||||
                        change={change}
 | 
			
		||||
@ -212,6 +217,8 @@ export const FeatureChange: FC<{
 | 
			
		||||
                        environmentName={changeRequest.environment}
 | 
			
		||||
                        projectId={changeRequest.project}
 | 
			
		||||
                        changeRequestState={changeRequest.state}
 | 
			
		||||
                        feature={feature}
 | 
			
		||||
                        onRefetch={onRefetch}
 | 
			
		||||
                    />
 | 
			
		||||
                )}
 | 
			
		||||
            </ChangeInnerBox>
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,102 @@
 | 
			
		||||
import { styled } from '@mui/material';
 | 
			
		||||
import type { IReleasePlan } from 'interfaces/releasePlans';
 | 
			
		||||
import type { UpdateMilestoneProgressionSchema } from 'openapi';
 | 
			
		||||
import type { ChangeRequestState } from 'component/changeRequest/changeRequest.types';
 | 
			
		||||
import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone';
 | 
			
		||||
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';
 | 
			
		||||
import { Badge } from 'component/common/Badge/Badge';
 | 
			
		||||
 | 
			
		||||
const StyledConnection = styled('div')(({ theme }) => ({
 | 
			
		||||
    width: 2,
 | 
			
		||||
    height: theme.spacing(2),
 | 
			
		||||
    backgroundColor: theme.palette.divider,
 | 
			
		||||
    marginLeft: theme.spacing(3.25),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
interface MilestoneListRendererProps {
 | 
			
		||||
    plan: IReleasePlan;
 | 
			
		||||
    changeRequestState: ChangeRequestState;
 | 
			
		||||
    milestonesWithAutomation?: Set<string>;
 | 
			
		||||
    milestonesWithDeletedAutomation?: Set<string>;
 | 
			
		||||
    onUpdateAutomation?: (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => Promise<void>;
 | 
			
		||||
    onDeleteAutomation?: (sourceMilestoneId: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MilestoneListRenderer = ({
 | 
			
		||||
    plan,
 | 
			
		||||
    changeRequestState,
 | 
			
		||||
    milestonesWithAutomation = new Set(),
 | 
			
		||||
    milestonesWithDeletedAutomation = new Set(),
 | 
			
		||||
    onUpdateAutomation,
 | 
			
		||||
    onDeleteAutomation,
 | 
			
		||||
}: MilestoneListRendererProps) => {
 | 
			
		||||
    // TODO: Split into read and write model at the type level to avoid having optional handlers
 | 
			
		||||
    const readonly =
 | 
			
		||||
        changeRequestState === 'Applied' || changeRequestState === 'Cancelled';
 | 
			
		||||
    const status: MilestoneStatus = 'not-started';
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
            {plan.milestones.map((milestone, index) => {
 | 
			
		||||
                const isNotLastMilestone = index < plan.milestones.length - 1;
 | 
			
		||||
                const shouldShowAutomation =
 | 
			
		||||
                    milestonesWithAutomation.has(milestone.id) ||
 | 
			
		||||
                    milestonesWithDeletedAutomation.has(milestone.id);
 | 
			
		||||
 | 
			
		||||
                const showAutomation =
 | 
			
		||||
                    isNotLastMilestone && shouldShowAutomation;
 | 
			
		||||
 | 
			
		||||
                const hasPendingDelete = milestonesWithDeletedAutomation.has(
 | 
			
		||||
                    milestone.id,
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                const badge = hasPendingDelete ? (
 | 
			
		||||
                    <Badge color='error'>Deleted in draft</Badge>
 | 
			
		||||
                ) : undefined;
 | 
			
		||||
 | 
			
		||||
                const automationSection =
 | 
			
		||||
                    showAutomation && milestone.transitionCondition ? (
 | 
			
		||||
                        <MilestoneAutomationSection status={status}>
 | 
			
		||||
                            <MilestoneTransitionDisplay
 | 
			
		||||
                                intervalMinutes={
 | 
			
		||||
                                    milestone.transitionCondition
 | 
			
		||||
                                        .intervalMinutes
 | 
			
		||||
                                }
 | 
			
		||||
                                onSave={async (payload) => {
 | 
			
		||||
                                    await onUpdateAutomation?.(
 | 
			
		||||
                                        milestone.id,
 | 
			
		||||
                                        payload,
 | 
			
		||||
                                    );
 | 
			
		||||
                                    return { shouldReset: true };
 | 
			
		||||
                                }}
 | 
			
		||||
                                onDelete={() =>
 | 
			
		||||
                                    onDeleteAutomation?.(milestone.id)
 | 
			
		||||
                                }
 | 
			
		||||
                                milestoneName={milestone.name}
 | 
			
		||||
                                status={status}
 | 
			
		||||
                                badge={badge}
 | 
			
		||||
                            />
 | 
			
		||||
                        </MilestoneAutomationSection>
 | 
			
		||||
                    ) : undefined;
 | 
			
		||||
 | 
			
		||||
                return (
 | 
			
		||||
                    <div key={milestone.id}>
 | 
			
		||||
                        <ReleasePlanMilestone
 | 
			
		||||
                            readonly={readonly}
 | 
			
		||||
                            milestone={milestone}
 | 
			
		||||
                            automationSection={automationSection}
 | 
			
		||||
                            allMilestones={plan.milestones}
 | 
			
		||||
                            activeMilestoneId={plan.activeMilestoneId}
 | 
			
		||||
                        />
 | 
			
		||||
                        {isNotLastMilestone && <StyledConnection />}
 | 
			
		||||
                    </div>
 | 
			
		||||
                );
 | 
			
		||||
            })}
 | 
			
		||||
        </>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,123 @@
 | 
			
		||||
import type { FC, ReactNode } from 'react';
 | 
			
		||||
import { Typography } from '@mui/material';
 | 
			
		||||
import type {
 | 
			
		||||
    ChangeRequestState,
 | 
			
		||||
    IChangeRequestCreateMilestoneProgression,
 | 
			
		||||
    IChangeRequestUpdateMilestoneProgression,
 | 
			
		||||
} from 'component/changeRequest/changeRequest.types';
 | 
			
		||||
import type { IReleasePlan } from 'interfaces/releasePlans';
 | 
			
		||||
import type { UpdateMilestoneProgressionSchema } from 'openapi';
 | 
			
		||||
import { EventDiff } from 'component/events/EventDiff/EventDiff';
 | 
			
		||||
import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx';
 | 
			
		||||
import {
 | 
			
		||||
    Action,
 | 
			
		||||
    Added,
 | 
			
		||||
    ChangeItemInfo,
 | 
			
		||||
    ChangeItemWrapper,
 | 
			
		||||
} from './Change.styles.tsx';
 | 
			
		||||
import { styled } from '@mui/material';
 | 
			
		||||
import { MilestoneListRenderer } from './MilestoneListRenderer.tsx';
 | 
			
		||||
import { applyProgressionChanges } from './applyProgressionChanges.ts';
 | 
			
		||||
 | 
			
		||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexFlow: 'column',
 | 
			
		||||
    gap: theme.spacing(1),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
interface ProgressionChangeProps {
 | 
			
		||||
    change:
 | 
			
		||||
        | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
        | IChangeRequestUpdateMilestoneProgression;
 | 
			
		||||
    currentReleasePlan?: IReleasePlan;
 | 
			
		||||
    actions?: ReactNode;
 | 
			
		||||
    changeRequestState: ChangeRequestState;
 | 
			
		||||
    onUpdateChangeRequestSubmit?: (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => Promise<void>;
 | 
			
		||||
    onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ProgressionChange: FC<ProgressionChangeProps> = ({
 | 
			
		||||
    change,
 | 
			
		||||
    currentReleasePlan,
 | 
			
		||||
    actions,
 | 
			
		||||
    changeRequestState,
 | 
			
		||||
    onUpdateChangeRequestSubmit,
 | 
			
		||||
    onDeleteChangeRequestSubmit,
 | 
			
		||||
}) => {
 | 
			
		||||
    const basePlan = change.payload.snapshot || currentReleasePlan;
 | 
			
		||||
    if (!basePlan) return null;
 | 
			
		||||
 | 
			
		||||
    const isCreate = change.action === 'createMilestoneProgression';
 | 
			
		||||
    const sourceId = change.payload.sourceMilestone;
 | 
			
		||||
 | 
			
		||||
    if (!sourceId) return null;
 | 
			
		||||
 | 
			
		||||
    const sourceMilestone = basePlan.milestones.find(
 | 
			
		||||
        (milestone) => milestone.id === sourceId,
 | 
			
		||||
    );
 | 
			
		||||
    const sourceMilestoneName = sourceMilestone?.name || sourceId;
 | 
			
		||||
 | 
			
		||||
    const targetMilestoneName = isCreate
 | 
			
		||||
        ? basePlan.milestones.find(
 | 
			
		||||
              (milestone) => milestone.id === change.payload.targetMilestone,
 | 
			
		||||
          )?.name || change.payload.targetMilestone
 | 
			
		||||
        : undefined;
 | 
			
		||||
 | 
			
		||||
    const modifiedPlan = applyProgressionChanges(basePlan, [change]);
 | 
			
		||||
 | 
			
		||||
    const previousMilestone = sourceMilestone;
 | 
			
		||||
    const newMilestone = modifiedPlan.milestones.find(
 | 
			
		||||
        (milestone) => milestone.id === sourceId,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledTabs>
 | 
			
		||||
            <ChangeItemWrapper>
 | 
			
		||||
                <ChangeItemInfo>
 | 
			
		||||
                    {isCreate ? (
 | 
			
		||||
                        <>
 | 
			
		||||
                            <Added>Adding automation to release plan</Added>
 | 
			
		||||
                            <Typography component='span'>
 | 
			
		||||
                                {sourceMilestoneName} → {targetMilestoneName}
 | 
			
		||||
                            </Typography>
 | 
			
		||||
                        </>
 | 
			
		||||
                    ) : (
 | 
			
		||||
                        <>
 | 
			
		||||
                            <Action>Updating automation in release plan</Action>
 | 
			
		||||
                            <Typography component='span'>
 | 
			
		||||
                                {sourceMilestoneName}
 | 
			
		||||
                            </Typography>
 | 
			
		||||
                        </>
 | 
			
		||||
                    )}
 | 
			
		||||
                </ChangeItemInfo>
 | 
			
		||||
                <div>
 | 
			
		||||
                    <TabList>
 | 
			
		||||
                        <Tab>View change</Tab>
 | 
			
		||||
                        <Tab>View diff</Tab>
 | 
			
		||||
                    </TabList>
 | 
			
		||||
                    {actions}
 | 
			
		||||
                </div>
 | 
			
		||||
            </ChangeItemWrapper>
 | 
			
		||||
            <TabPanel>
 | 
			
		||||
                <MilestoneListRenderer
 | 
			
		||||
                    plan={modifiedPlan}
 | 
			
		||||
                    changeRequestState={changeRequestState}
 | 
			
		||||
                    milestonesWithAutomation={new Set([sourceId])}
 | 
			
		||||
                    onUpdateAutomation={onUpdateChangeRequestSubmit}
 | 
			
		||||
                    onDeleteAutomation={onDeleteChangeRequestSubmit}
 | 
			
		||||
                />
 | 
			
		||||
            </TabPanel>
 | 
			
		||||
            <TabPanel variant='diff'>
 | 
			
		||||
                <EventDiff
 | 
			
		||||
                    entry={{
 | 
			
		||||
                        preData: previousMilestone,
 | 
			
		||||
                        data: newMilestone,
 | 
			
		||||
                    }}
 | 
			
		||||
                />
 | 
			
		||||
            </TabPanel>
 | 
			
		||||
        </StyledTabs>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -5,6 +5,9 @@ import type {
 | 
			
		||||
    IChangeRequestAddReleasePlan,
 | 
			
		||||
    IChangeRequestDeleteReleasePlan,
 | 
			
		||||
    IChangeRequestStartMilestone,
 | 
			
		||||
    IChangeRequestCreateMilestoneProgression,
 | 
			
		||||
    IChangeRequestUpdateMilestoneProgression,
 | 
			
		||||
    IChangeRequestDeleteMilestoneProgression,
 | 
			
		||||
} from 'component/changeRequest/changeRequest.types';
 | 
			
		||||
import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview';
 | 
			
		||||
import { useFeatureReleasePlans } from 'hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans';
 | 
			
		||||
@ -21,6 +24,12 @@ import {
 | 
			
		||||
    ChangeItemWrapper,
 | 
			
		||||
    Deleted,
 | 
			
		||||
} from './Change.styles.tsx';
 | 
			
		||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
 | 
			
		||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import type { UpdateMilestoneProgressionSchema } from 'openapi';
 | 
			
		||||
import { ProgressionChange } from './ProgressionChange.tsx';
 | 
			
		||||
import { ConsolidatedProgressionChanges } from './ConsolidatedProgressionChanges.tsx';
 | 
			
		||||
 | 
			
		||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
@ -235,11 +244,16 @@ export const ReleasePlanChange: FC<{
 | 
			
		||||
    change:
 | 
			
		||||
        | IChangeRequestAddReleasePlan
 | 
			
		||||
        | IChangeRequestDeleteReleasePlan
 | 
			
		||||
        | IChangeRequestStartMilestone;
 | 
			
		||||
        | IChangeRequestStartMilestone
 | 
			
		||||
        | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
        | IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
        | IChangeRequestDeleteMilestoneProgression;
 | 
			
		||||
    environmentName: string;
 | 
			
		||||
    featureName: string;
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    changeRequestState: ChangeRequestState;
 | 
			
		||||
    feature?: any; // Optional feature object for consolidated progression changes
 | 
			
		||||
    onRefetch?: () => void;
 | 
			
		||||
}> = ({
 | 
			
		||||
    actions,
 | 
			
		||||
    change,
 | 
			
		||||
@ -247,13 +261,100 @@ export const ReleasePlanChange: FC<{
 | 
			
		||||
    environmentName,
 | 
			
		||||
    projectId,
 | 
			
		||||
    changeRequestState,
 | 
			
		||||
    feature,
 | 
			
		||||
    onRefetch,
 | 
			
		||||
}) => {
 | 
			
		||||
    const { releasePlans } = useFeatureReleasePlans(
 | 
			
		||||
    const { releasePlans, refetch } = useFeatureReleasePlans(
 | 
			
		||||
        projectId,
 | 
			
		||||
        featureName,
 | 
			
		||||
        environmentName,
 | 
			
		||||
    );
 | 
			
		||||
    const currentReleasePlan = releasePlans[0];
 | 
			
		||||
    const { addChange } = useChangeRequestApi();
 | 
			
		||||
    const { refetch: refetchChangeRequests } =
 | 
			
		||||
        usePendingChangeRequests(projectId);
 | 
			
		||||
    const { setToastData } = useToast();
 | 
			
		||||
 | 
			
		||||
    const handleUpdateChangeRequestSubmit = async (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => {
 | 
			
		||||
        await addChange(projectId, environmentName, {
 | 
			
		||||
            feature: featureName,
 | 
			
		||||
            action: 'updateMilestoneProgression',
 | 
			
		||||
            payload: {
 | 
			
		||||
                sourceMilestone: sourceMilestoneId,
 | 
			
		||||
                ...payload,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
        await refetchChangeRequests();
 | 
			
		||||
        setToastData({
 | 
			
		||||
            type: 'success',
 | 
			
		||||
            text: 'Added to draft',
 | 
			
		||||
        });
 | 
			
		||||
        if (onRefetch) {
 | 
			
		||||
            await onRefetch();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleDeleteChangeRequestSubmit = async (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
    ) => {
 | 
			
		||||
        await addChange(projectId, environmentName, {
 | 
			
		||||
            feature: featureName,
 | 
			
		||||
            action: 'deleteMilestoneProgression',
 | 
			
		||||
            payload: {
 | 
			
		||||
                sourceMilestone: sourceMilestoneId,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
        await refetchChangeRequests();
 | 
			
		||||
        setToastData({
 | 
			
		||||
            type: 'success',
 | 
			
		||||
            text: 'Added to draft',
 | 
			
		||||
        });
 | 
			
		||||
        if (onRefetch) {
 | 
			
		||||
            await onRefetch();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // If this is a progression change and we have the full feature object,
 | 
			
		||||
    // check if we should consolidate with other progression changes
 | 
			
		||||
    if (
 | 
			
		||||
        feature &&
 | 
			
		||||
        (change.action === 'createMilestoneProgression' ||
 | 
			
		||||
            change.action === 'updateMilestoneProgression' ||
 | 
			
		||||
            change.action === 'deleteMilestoneProgression')
 | 
			
		||||
    ) {
 | 
			
		||||
        const progressionChanges = feature.changes.filter(
 | 
			
		||||
            (
 | 
			
		||||
                change,
 | 
			
		||||
            ): change is
 | 
			
		||||
                | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
                | IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
                | IChangeRequestDeleteMilestoneProgression =>
 | 
			
		||||
                change.action === 'createMilestoneProgression' ||
 | 
			
		||||
                change.action === 'updateMilestoneProgression' ||
 | 
			
		||||
                change.action === 'deleteMilestoneProgression',
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Only render if this is the first progression change
 | 
			
		||||
        const isFirstProgression =
 | 
			
		||||
            progressionChanges.length > 0 && progressionChanges[0] === change;
 | 
			
		||||
 | 
			
		||||
        if (!isFirstProgression) {
 | 
			
		||||
            return null; // Skip rendering, will be handled by the first one
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
            <ConsolidatedProgressionChanges
 | 
			
		||||
                feature={feature}
 | 
			
		||||
                currentReleasePlan={currentReleasePlan}
 | 
			
		||||
                changeRequestState={changeRequestState}
 | 
			
		||||
                onUpdateChangeRequestSubmit={handleUpdateChangeRequestSubmit}
 | 
			
		||||
                onDeleteChangeRequestSubmit={handleDeleteChangeRequestSubmit}
 | 
			
		||||
            />
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
@ -282,6 +383,21 @@ export const ReleasePlanChange: FC<{
 | 
			
		||||
                    actions={actions}
 | 
			
		||||
                />
 | 
			
		||||
            )}
 | 
			
		||||
            {(change.action === 'createMilestoneProgression' ||
 | 
			
		||||
                change.action === 'updateMilestoneProgression') && (
 | 
			
		||||
                <ProgressionChange
 | 
			
		||||
                    change={change}
 | 
			
		||||
                    currentReleasePlan={currentReleasePlan}
 | 
			
		||||
                    actions={actions}
 | 
			
		||||
                    changeRequestState={changeRequestState}
 | 
			
		||||
                    onUpdateChangeRequestSubmit={
 | 
			
		||||
                        handleUpdateChangeRequestSubmit
 | 
			
		||||
                    }
 | 
			
		||||
                    onDeleteChangeRequestSubmit={
 | 
			
		||||
                        handleDeleteChangeRequestSubmit
 | 
			
		||||
                    }
 | 
			
		||||
                />
 | 
			
		||||
            )}
 | 
			
		||||
        </>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
import type { IReleasePlan } from 'interfaces/releasePlans';
 | 
			
		||||
import type {
 | 
			
		||||
    IChangeRequestCreateMilestoneProgression,
 | 
			
		||||
    IChangeRequestUpdateMilestoneProgression,
 | 
			
		||||
    IChangeRequestDeleteMilestoneProgression,
 | 
			
		||||
} from 'component/changeRequest/changeRequest.types';
 | 
			
		||||
 | 
			
		||||
type ProgressionChange =
 | 
			
		||||
    | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
    | IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
    | IChangeRequestDeleteMilestoneProgression;
 | 
			
		||||
 | 
			
		||||
export const applyProgressionChanges = (
 | 
			
		||||
    basePlan: IReleasePlan,
 | 
			
		||||
    progressionChanges: ProgressionChange[],
 | 
			
		||||
): IReleasePlan => {
 | 
			
		||||
    return {
 | 
			
		||||
        ...basePlan,
 | 
			
		||||
        milestones: basePlan.milestones.map((milestone) => {
 | 
			
		||||
            const createChange = progressionChanges.find(
 | 
			
		||||
                (change): change is IChangeRequestCreateMilestoneProgression =>
 | 
			
		||||
                    change.action === 'createMilestoneProgression' &&
 | 
			
		||||
                    change.payload.sourceMilestone === milestone.id,
 | 
			
		||||
            );
 | 
			
		||||
            const updateChange = progressionChanges.find(
 | 
			
		||||
                (change): change is IChangeRequestUpdateMilestoneProgression =>
 | 
			
		||||
                    change.action === 'updateMilestoneProgression' &&
 | 
			
		||||
                    change.payload.sourceMilestone === milestone.id,
 | 
			
		||||
            );
 | 
			
		||||
            const deleteChange = progressionChanges.find(
 | 
			
		||||
                (change): change is IChangeRequestDeleteMilestoneProgression =>
 | 
			
		||||
                    change.action === 'deleteMilestoneProgression' &&
 | 
			
		||||
                    change.payload.sourceMilestone === milestone.id,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (deleteChange) {
 | 
			
		||||
                return {
 | 
			
		||||
                    ...milestone,
 | 
			
		||||
                    transitionCondition: null,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const change = updateChange || createChange;
 | 
			
		||||
            if (change) {
 | 
			
		||||
                return {
 | 
			
		||||
                    ...milestone,
 | 
			
		||||
                    transitionCondition: change.payload.transitionCondition,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
            return milestone;
 | 
			
		||||
        }),
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
@ -137,7 +137,8 @@ type ChangeRequestPayload =
 | 
			
		||||
    | ChangeRequestDeleteReleasePlan
 | 
			
		||||
    | ChangeRequestStartMilestone
 | 
			
		||||
    | ChangeRequestCreateMilestoneProgression
 | 
			
		||||
    | ChangeRequestUpdateMilestoneProgression;
 | 
			
		||||
    | ChangeRequestUpdateMilestoneProgression
 | 
			
		||||
    | ChangeRequestDeleteMilestoneProgression;
 | 
			
		||||
 | 
			
		||||
export interface IChangeRequestAddStrategy extends IChangeRequestChangeBase {
 | 
			
		||||
    action: 'addStrategy';
 | 
			
		||||
@ -206,6 +207,12 @@ export interface IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
    payload: ChangeRequestUpdateMilestoneProgression;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IChangeRequestDeleteMilestoneProgression
 | 
			
		||||
    extends IChangeRequestChangeBase {
 | 
			
		||||
    action: 'deleteMilestoneProgression';
 | 
			
		||||
    payload: ChangeRequestDeleteMilestoneProgression;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IChangeRequestReorderStrategy
 | 
			
		||||
    extends IChangeRequestChangeBase {
 | 
			
		||||
    action: 'reorderStrategy';
 | 
			
		||||
@ -255,7 +262,8 @@ export type IFeatureChange =
 | 
			
		||||
    | IChangeRequestDeleteReleasePlan
 | 
			
		||||
    | IChangeRequestStartMilestone
 | 
			
		||||
    | IChangeRequestCreateMilestoneProgression
 | 
			
		||||
    | IChangeRequestUpdateMilestoneProgression;
 | 
			
		||||
    | IChangeRequestUpdateMilestoneProgression
 | 
			
		||||
    | IChangeRequestDeleteMilestoneProgression;
 | 
			
		||||
 | 
			
		||||
export type ISegmentChange =
 | 
			
		||||
    | IChangeRequestUpdateSegment
 | 
			
		||||
@ -288,13 +296,24 @@ type ChangeRequestStartMilestone = {
 | 
			
		||||
    snapshot?: IReleasePlan;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ChangeRequestCreateMilestoneProgression = CreateMilestoneProgressionSchema;
 | 
			
		||||
type ChangeRequestCreateMilestoneProgression =
 | 
			
		||||
    CreateMilestoneProgressionSchema & {
 | 
			
		||||
        snapshot?: IReleasePlan;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
type ChangeRequestUpdateMilestoneProgression =
 | 
			
		||||
    UpdateMilestoneProgressionSchema & {
 | 
			
		||||
        sourceMilestoneId: string;
 | 
			
		||||
        sourceMilestoneId?: string;
 | 
			
		||||
        sourceMilestone?: string; // Backward compatibility for existing change requests
 | 
			
		||||
        snapshot?: IReleasePlan;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
type ChangeRequestDeleteMilestoneProgression = {
 | 
			
		||||
    sourceMilestoneId?: string;
 | 
			
		||||
    sourceMilestone?: string; // Backward compatibility for existing change requests
 | 
			
		||||
    snapshot?: IReleasePlan;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ChangeRequestAddStrategy = Pick<
 | 
			
		||||
    IFeatureStrategy,
 | 
			
		||||
    | 'parameters'
 | 
			
		||||
@ -334,4 +353,5 @@ export type ChangeRequestAction =
 | 
			
		||||
    | 'deleteReleasePlan'
 | 
			
		||||
    | 'startMilestone'
 | 
			
		||||
    | 'createMilestoneProgression'
 | 
			
		||||
    | 'updateMilestoneProgression';
 | 
			
		||||
    | 'updateMilestoneProgression'
 | 
			
		||||
    | 'deleteMilestoneProgression';
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,7 @@
 | 
			
		||||
import { useState } from 'react';
 | 
			
		||||
import { Button, styled } from '@mui/material';
 | 
			
		||||
import BoltIcon from '@mui/icons-material/Bolt';
 | 
			
		||||
import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js';
 | 
			
		||||
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
 | 
			
		||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
 | 
			
		||||
import type { CreateMilestoneProgressionSchema } from 'openapi';
 | 
			
		||||
 | 
			
		||||
const StyledFormContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
@ -60,74 +55,27 @@ const StyledErrorMessage = styled('span')(({ theme }) => ({
 | 
			
		||||
interface IMilestoneProgressionFormProps {
 | 
			
		||||
    sourceMilestoneId: string;
 | 
			
		||||
    targetMilestoneId: string;
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    environment: string;
 | 
			
		||||
    featureName: string;
 | 
			
		||||
    onSave: () => void;
 | 
			
		||||
    onSubmit: (payload: CreateMilestoneProgressionSchema) => Promise<void>;
 | 
			
		||||
    onCancel: () => void;
 | 
			
		||||
    onChangeRequestSubmit?: (
 | 
			
		||||
        progressionPayload: CreateMilestoneProgressionSchema,
 | 
			
		||||
    ) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MilestoneProgressionForm = ({
 | 
			
		||||
    sourceMilestoneId,
 | 
			
		||||
    targetMilestoneId,
 | 
			
		||||
    projectId,
 | 
			
		||||
    environment,
 | 
			
		||||
    featureName,
 | 
			
		||||
    onSave,
 | 
			
		||||
    onSubmit,
 | 
			
		||||
    onCancel,
 | 
			
		||||
    onChangeRequestSubmit,
 | 
			
		||||
}: IMilestoneProgressionFormProps) => {
 | 
			
		||||
    const form = useMilestoneProgressionForm(
 | 
			
		||||
        sourceMilestoneId,
 | 
			
		||||
        targetMilestoneId,
 | 
			
		||||
    );
 | 
			
		||||
    const { createMilestoneProgression } = useMilestoneProgressionsApi();
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
    const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
 | 
			
		||||
 | 
			
		||||
    const [isSubmitting, setIsSubmitting] = useState(false);
 | 
			
		||||
 | 
			
		||||
    const handleChangeRequestSubmit = () => {
 | 
			
		||||
        const progressionPayload = form.getProgressionPayload();
 | 
			
		||||
        onChangeRequestSubmit?.(progressionPayload);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleDirectSubmit = async () => {
 | 
			
		||||
        setIsSubmitting(true);
 | 
			
		||||
        try {
 | 
			
		||||
            await createMilestoneProgression(
 | 
			
		||||
                projectId,
 | 
			
		||||
                environment,
 | 
			
		||||
                featureName,
 | 
			
		||||
                form.getProgressionPayload(),
 | 
			
		||||
            );
 | 
			
		||||
            setToastData({
 | 
			
		||||
                type: 'success',
 | 
			
		||||
                text: 'Automation configured successfully',
 | 
			
		||||
            });
 | 
			
		||||
            onSave();
 | 
			
		||||
        } catch (error: unknown) {
 | 
			
		||||
            setToastApiError(formatUnknownError(error));
 | 
			
		||||
        } finally {
 | 
			
		||||
            setIsSubmitting(false);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = async () => {
 | 
			
		||||
        if (isSubmitting) return;
 | 
			
		||||
 | 
			
		||||
        if (!form.validate()) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isChangeRequestConfigured(environment) && onChangeRequestSubmit) {
 | 
			
		||||
            handleChangeRequestSubmit();
 | 
			
		||||
        } else {
 | 
			
		||||
            await handleDirectSubmit();
 | 
			
		||||
        }
 | 
			
		||||
        await onSubmit(form.getProgressionPayload());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleKeyDown = (event: React.KeyboardEvent) => {
 | 
			
		||||
@ -150,19 +98,13 @@ export const MilestoneProgressionForm = ({
 | 
			
		||||
                    timeUnit={form.timeUnit}
 | 
			
		||||
                    onTimeValueChange={form.handleTimeValueChange}
 | 
			
		||||
                    onTimeUnitChange={form.handleTimeUnitChange}
 | 
			
		||||
                    disabled={isSubmitting}
 | 
			
		||||
                />
 | 
			
		||||
            </StyledTopRow>
 | 
			
		||||
            <StyledButtonGroup>
 | 
			
		||||
                {form.errors.time && (
 | 
			
		||||
                    <StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
 | 
			
		||||
                )}
 | 
			
		||||
                <Button
 | 
			
		||||
                    variant='outlined'
 | 
			
		||||
                    onClick={onCancel}
 | 
			
		||||
                    size='small'
 | 
			
		||||
                    disabled={isSubmitting}
 | 
			
		||||
                >
 | 
			
		||||
                <Button variant='outlined' onClick={onCancel} size='small'>
 | 
			
		||||
                    Cancel
 | 
			
		||||
                </Button>
 | 
			
		||||
                <Button
 | 
			
		||||
@ -170,9 +112,8 @@ export const MilestoneProgressionForm = ({
 | 
			
		||||
                    color='primary'
 | 
			
		||||
                    onClick={handleSubmit}
 | 
			
		||||
                    size='small'
 | 
			
		||||
                    disabled={isSubmitting}
 | 
			
		||||
                >
 | 
			
		||||
                    {isSubmitting ? 'Saving...' : 'Save'}
 | 
			
		||||
                    Save
 | 
			
		||||
                </Button>
 | 
			
		||||
            </StyledButtonGroup>
 | 
			
		||||
        </StyledFormContainer>
 | 
			
		||||
 | 
			
		||||
@ -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,13 +20,13 @@ 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 {
 | 
			
		||||
    CreateMilestoneProgressionSchema,
 | 
			
		||||
    UpdateMilestoneProgressionSchema,
 | 
			
		||||
} from 'openapi';
 | 
			
		||||
import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx';
 | 
			
		||||
 | 
			
		||||
const StyledContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
    padding: theme.spacing(2),
 | 
			
		||||
@ -75,17 +73,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;
 | 
			
		||||
@ -140,8 +127,47 @@ export const ReleasePlan = ({
 | 
			
		||||
    >(null);
 | 
			
		||||
    const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
 | 
			
		||||
    const { addChange } = useChangeRequestApi();
 | 
			
		||||
    const { refetch: refetchChangeRequests } =
 | 
			
		||||
    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
 | 
			
		||||
@ -181,7 +207,6 @@ export const ReleasePlan = ({
 | 
			
		||||
                    action: 'createMilestoneProgression',
 | 
			
		||||
                    payload: changeRequestAction.payload,
 | 
			
		||||
                });
 | 
			
		||||
                setProgressionFormOpenIndex(null);
 | 
			
		||||
                break;
 | 
			
		||||
 | 
			
		||||
            case 'updateMilestoneProgression':
 | 
			
		||||
@ -214,6 +239,7 @@ export const ReleasePlan = ({
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        setChangeRequestAction(null);
 | 
			
		||||
        setProgressionFormOpenIndex(null);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const confirmRemoveReleasePlan = () => {
 | 
			
		||||
@ -288,33 +314,19 @@ export const ReleasePlan = ({
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleProgressionSave = async () => {
 | 
			
		||||
        setProgressionFormOpenIndex(null);
 | 
			
		||||
        await refetch();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleProgressionCancel = () => {
 | 
			
		||||
        setProgressionFormOpenIndex(null);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleProgressionChangeRequestSubmit = (
 | 
			
		||||
        payload: CreateMilestoneProgressionSchema,
 | 
			
		||||
    const handleAddToChangeRequest = (
 | 
			
		||||
        action:
 | 
			
		||||
            | {
 | 
			
		||||
                  type: 'createMilestoneProgression';
 | 
			
		||||
                  payload: CreateMilestoneProgressionSchema;
 | 
			
		||||
              }
 | 
			
		||||
            | {
 | 
			
		||||
                  type: 'updateMilestoneProgression';
 | 
			
		||||
                  sourceMilestoneId: string;
 | 
			
		||||
                  payload: UpdateMilestoneProgressionSchema;
 | 
			
		||||
              },
 | 
			
		||||
    ) => {
 | 
			
		||||
        setChangeRequestAction({
 | 
			
		||||
            type: 'createMilestoneProgression',
 | 
			
		||||
            payload,
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleUpdateProgressionChangeRequestSubmit = (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => {
 | 
			
		||||
        setChangeRequestAction({
 | 
			
		||||
            type: 'updateMilestoneProgression',
 | 
			
		||||
            sourceMilestoneId,
 | 
			
		||||
            payload,
 | 
			
		||||
        });
 | 
			
		||||
        setChangeRequestAction(action);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleDeleteProgression = (milestone: IReleasePlanMilestone) => {
 | 
			
		||||
@ -392,80 +404,35 @@ 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}
 | 
			
		||||
                                milestone={milestone}
 | 
			
		||||
                                status={
 | 
			
		||||
                                    milestone.id === activeMilestoneId
 | 
			
		||||
                                        ? environmentIsDisabled
 | 
			
		||||
                                            ? 'paused'
 | 
			
		||||
                                            : 'active'
 | 
			
		||||
                                        : index < activeIndex
 | 
			
		||||
                                          ? 'completed'
 | 
			
		||||
                                          : 'not-started'
 | 
			
		||||
                                }
 | 
			
		||||
                                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
 | 
			
		||||
                                }
 | 
			
		||||
                                projectId={projectId}
 | 
			
		||||
                                environment={environment}
 | 
			
		||||
                                featureName={featureName}
 | 
			
		||||
                                onUpdate={refetch}
 | 
			
		||||
                                onUpdateChangeRequestSubmit={
 | 
			
		||||
                                    handleUpdateProgressionChangeRequestSubmit
 | 
			
		||||
                                }
 | 
			
		||||
                                allMilestones={milestones}
 | 
			
		||||
                                activeMilestoneId={activeMilestoneId}
 | 
			
		||||
                            />
 | 
			
		||||
                            <ConditionallyRender
 | 
			
		||||
                                condition={isNotLastMilestone}
 | 
			
		||||
                                show={
 | 
			
		||||
                                    <StyledConnection
 | 
			
		||||
                                        isCompleted={index < activeIndex}
 | 
			
		||||
                                    />
 | 
			
		||||
                                }
 | 
			
		||||
                            />
 | 
			
		||||
                        </div>
 | 
			
		||||
                    );
 | 
			
		||||
                })}
 | 
			
		||||
                {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}
 | 
			
		||||
 | 
			
		||||
@ -1,8 +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';
 | 
			
		||||
 | 
			
		||||
const StyledAutomationContainer = styled('div', {
 | 
			
		||||
    shouldForwardProp: (prop) => prop !== 'status',
 | 
			
		||||
@ -24,97 +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,
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
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) => {
 | 
			
		||||
    if (!showAutomation) return null;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledAutomationContainer status={status}>
 | 
			
		||||
            {automationForm ? (
 | 
			
		||||
                automationForm
 | 
			
		||||
            ) : transitionCondition ? (
 | 
			
		||||
                <MilestoneTransitionDisplay
 | 
			
		||||
                    intervalMinutes={transitionCondition.intervalMinutes}
 | 
			
		||||
                    onDelete={onDeleteAutomation!}
 | 
			
		||||
                    milestoneName={milestoneName}
 | 
			
		||||
                    status={status}
 | 
			
		||||
                    projectId={projectId}
 | 
			
		||||
                    environment={environment}
 | 
			
		||||
                    featureName={featureName}
 | 
			
		||||
                    sourceMilestoneId={sourceMilestoneId}
 | 
			
		||||
                    onUpdate={onUpdate}
 | 
			
		||||
                    onChangeRequestSubmit={onUpdateChangeRequestSubmit}
 | 
			
		||||
                />
 | 
			
		||||
            ) : (
 | 
			
		||||
                <StyledAddAutomationButton
 | 
			
		||||
                    onClick={onAddAutomation}
 | 
			
		||||
                    color='primary'
 | 
			
		||||
                    startIcon={<Add />}
 | 
			
		||||
                >
 | 
			
		||||
                    Add automation
 | 
			
		||||
                </StyledAddAutomationButton>
 | 
			
		||||
            )}
 | 
			
		||||
            {children}
 | 
			
		||||
        </StyledAutomationContainer>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -2,17 +2,14 @@ import BoltIcon from '@mui/icons-material/Bolt';
 | 
			
		||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
 | 
			
		||||
import { Button, IconButton, styled } from '@mui/material';
 | 
			
		||||
import type { MilestoneStatus } from './ReleasePlanMilestoneStatus.tsx';
 | 
			
		||||
import { useState } from 'react';
 | 
			
		||||
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { MilestoneProgressionTimeInput } from '../MilestoneProgressionForm/MilestoneProgressionTimeInput.tsx';
 | 
			
		||||
import {
 | 
			
		||||
    useMilestoneProgressionForm,
 | 
			
		||||
    getTimeValueAndUnitFromMinutes,
 | 
			
		||||
} from '../hooks/useMilestoneProgressionForm.js';
 | 
			
		||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
 | 
			
		||||
import type { UpdateMilestoneProgressionSchema } from 'openapi';
 | 
			
		||||
import type { ReactNode } from 'react';
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
const StyledDisplayContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
@ -61,52 +58,44 @@ const StyledButtonGroup = styled('div')(({ theme }) => ({
 | 
			
		||||
 | 
			
		||||
interface IMilestoneTransitionDisplayProps {
 | 
			
		||||
    intervalMinutes: number;
 | 
			
		||||
    onSave: (
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => Promise<{ shouldReset?: boolean }>;
 | 
			
		||||
    onDelete: () => void;
 | 
			
		||||
    milestoneName: string;
 | 
			
		||||
    status?: MilestoneStatus;
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    environment: string;
 | 
			
		||||
    featureName: string;
 | 
			
		||||
    sourceMilestoneId: string;
 | 
			
		||||
    onUpdate: () => void;
 | 
			
		||||
    onChangeRequestSubmit?: (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => void;
 | 
			
		||||
    badge?: ReactNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MilestoneTransitionDisplay = ({
 | 
			
		||||
    intervalMinutes,
 | 
			
		||||
    onSave,
 | 
			
		||||
    onDelete,
 | 
			
		||||
    milestoneName,
 | 
			
		||||
    status,
 | 
			
		||||
    projectId,
 | 
			
		||||
    environment,
 | 
			
		||||
    featureName,
 | 
			
		||||
    sourceMilestoneId,
 | 
			
		||||
    onUpdate,
 | 
			
		||||
    onChangeRequestSubmit,
 | 
			
		||||
    badge,
 | 
			
		||||
}: IMilestoneTransitionDisplayProps) => {
 | 
			
		||||
    const { updateMilestoneProgression } = useMilestoneProgressionsApi();
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
    const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
 | 
			
		||||
 | 
			
		||||
    const initial = getTimeValueAndUnitFromMinutes(intervalMinutes);
 | 
			
		||||
    const form = useMilestoneProgressionForm(
 | 
			
		||||
        sourceMilestoneId,
 | 
			
		||||
        sourceMilestoneId, // We don't need targetMilestone for edit, just reuse source
 | 
			
		||||
        '', // sourceMilestoneId not needed for display
 | 
			
		||||
        '', // targetMilestoneId not needed for display
 | 
			
		||||
        {
 | 
			
		||||
            timeValue: initial.value,
 | 
			
		||||
            timeUnit: initial.unit,
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
    const [isSubmitting, setIsSubmitting] = useState(false);
 | 
			
		||||
 | 
			
		||||
    const currentIntervalMinutes = form.getIntervalMinutes();
 | 
			
		||||
    const hasChanged = currentIntervalMinutes !== intervalMinutes;
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const newInitial = getTimeValueAndUnitFromMinutes(intervalMinutes);
 | 
			
		||||
        form.setTimeValue(newInitial.value);
 | 
			
		||||
        form.setTimeUnit(newInitial.unit);
 | 
			
		||||
    }, [intervalMinutes]);
 | 
			
		||||
 | 
			
		||||
    const handleSave = async () => {
 | 
			
		||||
        if (isSubmitting || !hasChanged) return;
 | 
			
		||||
        if (!hasChanged) return;
 | 
			
		||||
 | 
			
		||||
        const payload: UpdateMilestoneProgressionSchema = {
 | 
			
		||||
            transitionCondition: {
 | 
			
		||||
@ -114,29 +103,10 @@ export const MilestoneTransitionDisplay = ({
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (isChangeRequestConfigured(environment) && onChangeRequestSubmit) {
 | 
			
		||||
            onChangeRequestSubmit(sourceMilestoneId, payload);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const result = await onSave(payload);
 | 
			
		||||
 | 
			
		||||
        setIsSubmitting(true);
 | 
			
		||||
        try {
 | 
			
		||||
            await updateMilestoneProgression(
 | 
			
		||||
                projectId,
 | 
			
		||||
                environment,
 | 
			
		||||
                featureName,
 | 
			
		||||
                sourceMilestoneId,
 | 
			
		||||
                payload,
 | 
			
		||||
            );
 | 
			
		||||
            setToastData({
 | 
			
		||||
                type: 'success',
 | 
			
		||||
                text: 'Automation updated successfully',
 | 
			
		||||
            });
 | 
			
		||||
            onUpdate();
 | 
			
		||||
        } catch (error: unknown) {
 | 
			
		||||
            setToastApiError(formatUnknownError(error));
 | 
			
		||||
        } finally {
 | 
			
		||||
            setIsSubmitting(false);
 | 
			
		||||
        if (result?.shouldReset) {
 | 
			
		||||
            handleReset();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -168,7 +138,6 @@ export const MilestoneTransitionDisplay = ({
 | 
			
		||||
                    timeUnit={form.timeUnit}
 | 
			
		||||
                    onTimeValueChange={form.handleTimeValueChange}
 | 
			
		||||
                    onTimeUnitChange={form.handleTimeUnitChange}
 | 
			
		||||
                    disabled={isSubmitting}
 | 
			
		||||
                />
 | 
			
		||||
            </StyledContentGroup>
 | 
			
		||||
            <StyledButtonGroup>
 | 
			
		||||
@ -178,17 +147,16 @@ export const MilestoneTransitionDisplay = ({
 | 
			
		||||
                        color='primary'
 | 
			
		||||
                        onClick={handleSave}
 | 
			
		||||
                        size='small'
 | 
			
		||||
                        disabled={isSubmitting}
 | 
			
		||||
                    >
 | 
			
		||||
                        {isSubmitting ? 'Saving...' : 'Save'}
 | 
			
		||||
                        Save
 | 
			
		||||
                    </Button>
 | 
			
		||||
                )}
 | 
			
		||||
                {badge}
 | 
			
		||||
                <IconButton
 | 
			
		||||
                    onClick={onDelete}
 | 
			
		||||
                    size='small'
 | 
			
		||||
                    aria-label={`Delete automation for ${milestoneName}`}
 | 
			
		||||
                    sx={{ padding: 0.5 }}
 | 
			
		||||
                    disabled={isSubmitting}
 | 
			
		||||
                >
 | 
			
		||||
                    <DeleteOutlineIcon fontSize='small' />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,141 @@
 | 
			
		||||
import Add from '@mui/icons-material/Add';
 | 
			
		||||
import { Button, styled } from '@mui/material';
 | 
			
		||||
import { Badge } from 'component/common/Badge/Badge';
 | 
			
		||||
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 type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
 | 
			
		||||
import { MilestoneProgressionForm } from '../MilestoneProgressionForm/MilestoneProgressionForm.tsx';
 | 
			
		||||
import type { PendingProgressionChange } from './ReleasePlanMilestoneItem.tsx';
 | 
			
		||||
 | 
			
		||||
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 MilestoneAutomationProps {
 | 
			
		||||
    milestone: IReleasePlanMilestone;
 | 
			
		||||
    status: MilestoneStatus;
 | 
			
		||||
    isNotLastMilestone: boolean;
 | 
			
		||||
    nextMilestoneId: string;
 | 
			
		||||
    milestoneProgressionsEnabled: boolean;
 | 
			
		||||
    readonly: boolean | undefined;
 | 
			
		||||
    isProgressionFormOpen: boolean;
 | 
			
		||||
    effectiveTransitionCondition: IReleasePlanMilestone['transitionCondition'];
 | 
			
		||||
    pendingProgressionChange: PendingProgressionChange | null;
 | 
			
		||||
    onOpenProgressionForm: () => void;
 | 
			
		||||
    onCloseProgressionForm: () => void;
 | 
			
		||||
    onCreateProgression: (
 | 
			
		||||
        payload: CreateMilestoneProgressionSchema,
 | 
			
		||||
    ) => Promise<void>;
 | 
			
		||||
    onUpdateProgression: (
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ) => Promise<{ shouldReset?: boolean }>;
 | 
			
		||||
    onDeleteProgression: (milestone: IReleasePlanMilestone) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MilestoneAutomation = ({
 | 
			
		||||
    milestone,
 | 
			
		||||
    status,
 | 
			
		||||
    isNotLastMilestone,
 | 
			
		||||
    nextMilestoneId,
 | 
			
		||||
    milestoneProgressionsEnabled,
 | 
			
		||||
    readonly,
 | 
			
		||||
    isProgressionFormOpen,
 | 
			
		||||
    effectiveTransitionCondition,
 | 
			
		||||
    pendingProgressionChange,
 | 
			
		||||
    onOpenProgressionForm,
 | 
			
		||||
    onCloseProgressionForm,
 | 
			
		||||
    onCreateProgression,
 | 
			
		||||
    onUpdateProgression,
 | 
			
		||||
    onDeleteProgression,
 | 
			
		||||
}: MilestoneAutomationProps) => {
 | 
			
		||||
    const showAutomation =
 | 
			
		||||
        milestoneProgressionsEnabled && isNotLastMilestone && !readonly;
 | 
			
		||||
 | 
			
		||||
    if (!showAutomation) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const hasPendingCreate =
 | 
			
		||||
        pendingProgressionChange?.action === 'createMilestoneProgression';
 | 
			
		||||
    const hasPendingUpdate =
 | 
			
		||||
        pendingProgressionChange?.action === 'updateMilestoneProgression';
 | 
			
		||||
    const hasPendingDelete =
 | 
			
		||||
        pendingProgressionChange?.action === 'deleteMilestoneProgression';
 | 
			
		||||
 | 
			
		||||
    const badge = hasPendingDelete ? (
 | 
			
		||||
        <Badge color='error'>Deleted in draft</Badge>
 | 
			
		||||
    ) : hasPendingUpdate ? (
 | 
			
		||||
        <Badge color='warning'>Modified in draft</Badge>
 | 
			
		||||
    ) : undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <MilestoneAutomationSection status={status}>
 | 
			
		||||
            {isProgressionFormOpen ? (
 | 
			
		||||
                <MilestoneProgressionForm
 | 
			
		||||
                    sourceMilestoneId={milestone.id}
 | 
			
		||||
                    targetMilestoneId={nextMilestoneId}
 | 
			
		||||
                    onSubmit={onCreateProgression}
 | 
			
		||||
                    onCancel={onCloseProgressionForm}
 | 
			
		||||
                />
 | 
			
		||||
            ) : effectiveTransitionCondition ? (
 | 
			
		||||
                <MilestoneTransitionDisplay
 | 
			
		||||
                    intervalMinutes={
 | 
			
		||||
                        effectiveTransitionCondition.intervalMinutes
 | 
			
		||||
                    }
 | 
			
		||||
                    onSave={onUpdateProgression}
 | 
			
		||||
                    onDelete={() => onDeleteProgression(milestone)}
 | 
			
		||||
                    milestoneName={milestone.name}
 | 
			
		||||
                    status={status}
 | 
			
		||||
                    badge={badge}
 | 
			
		||||
                />
 | 
			
		||||
            ) : (
 | 
			
		||||
                <StyledAddAutomationContainer>
 | 
			
		||||
                    <StyledAddAutomationButton
 | 
			
		||||
                        onClick={onOpenProgressionForm}
 | 
			
		||||
                        color='primary'
 | 
			
		||||
                        startIcon={<Add />}
 | 
			
		||||
                    >
 | 
			
		||||
                        Add automation
 | 
			
		||||
                    </StyledAddAutomationButton>
 | 
			
		||||
                    {hasPendingCreate && (
 | 
			
		||||
                        <Badge color='warning'>Modified in draft</Badge>
 | 
			
		||||
                    )}
 | 
			
		||||
                </StyledAddAutomationContainer>
 | 
			
		||||
            )}
 | 
			
		||||
        </MilestoneAutomationSection>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,213 @@
 | 
			
		||||
import { styled } from '@mui/material';
 | 
			
		||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
 | 
			
		||||
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
 | 
			
		||||
import type {
 | 
			
		||||
    CreateMilestoneProgressionSchema,
 | 
			
		||||
    UpdateMilestoneProgressionSchema,
 | 
			
		||||
} from 'openapi';
 | 
			
		||||
import { ReleasePlanMilestone } from '../ReleasePlanMilestone/ReleasePlanMilestone.tsx';
 | 
			
		||||
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
 | 
			
		||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
 | 
			
		||||
import useToast from 'hooks/useToast';
 | 
			
		||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
			
		||||
import { calculateMilestoneStatus } from './milestoneStatusUtils.js';
 | 
			
		||||
import { usePendingProgressionChanges } from './usePendingProgressionChanges.js';
 | 
			
		||||
import { MilestoneAutomation } from './MilestoneAutomation.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),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export interface PendingProgressionChange {
 | 
			
		||||
    action: string;
 | 
			
		||||
    payload: any;
 | 
			
		||||
    changeRequestId: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IReleasePlanMilestoneItemProps {
 | 
			
		||||
    milestone: IReleasePlanMilestone;
 | 
			
		||||
    index: number;
 | 
			
		||||
    milestones: IReleasePlanMilestone[];
 | 
			
		||||
    activeMilestoneId?: string;
 | 
			
		||||
    activeIndex: number;
 | 
			
		||||
    environmentIsDisabled?: boolean;
 | 
			
		||||
    readonly?: boolean;
 | 
			
		||||
    milestoneProgressionsEnabled: boolean;
 | 
			
		||||
    progressionFormOpenIndex: number | null;
 | 
			
		||||
    onSetProgressionFormOpenIndex: (index: number | null) => void;
 | 
			
		||||
    onStartMilestone?: (milestone: IReleasePlanMilestone) => void;
 | 
			
		||||
    onDeleteProgression: (milestone: IReleasePlanMilestone) => void;
 | 
			
		||||
    onAddToChangeRequest: (
 | 
			
		||||
        action:
 | 
			
		||||
            | {
 | 
			
		||||
                  type: 'createMilestoneProgression';
 | 
			
		||||
                  payload: CreateMilestoneProgressionSchema;
 | 
			
		||||
              }
 | 
			
		||||
            | {
 | 
			
		||||
                  type: 'updateMilestoneProgression';
 | 
			
		||||
                  sourceMilestoneId: string;
 | 
			
		||||
                  payload: UpdateMilestoneProgressionSchema;
 | 
			
		||||
              },
 | 
			
		||||
    ) => void;
 | 
			
		||||
    getPendingProgressionChange: (
 | 
			
		||||
        sourceMilestoneId: string,
 | 
			
		||||
    ) => PendingProgressionChange | null;
 | 
			
		||||
    projectId: string;
 | 
			
		||||
    environment: string;
 | 
			
		||||
    featureName: string;
 | 
			
		||||
    onUpdate: () => void | Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ReleasePlanMilestoneItem = ({
 | 
			
		||||
    milestone,
 | 
			
		||||
    index,
 | 
			
		||||
    milestones,
 | 
			
		||||
    activeMilestoneId,
 | 
			
		||||
    activeIndex,
 | 
			
		||||
    environmentIsDisabled,
 | 
			
		||||
    readonly,
 | 
			
		||||
    milestoneProgressionsEnabled,
 | 
			
		||||
    progressionFormOpenIndex,
 | 
			
		||||
    onSetProgressionFormOpenIndex,
 | 
			
		||||
    onStartMilestone,
 | 
			
		||||
    onDeleteProgression,
 | 
			
		||||
    onAddToChangeRequest,
 | 
			
		||||
    getPendingProgressionChange,
 | 
			
		||||
    projectId,
 | 
			
		||||
    environment,
 | 
			
		||||
    featureName,
 | 
			
		||||
    onUpdate,
 | 
			
		||||
}: IReleasePlanMilestoneItemProps) => {
 | 
			
		||||
    const { createMilestoneProgression, updateMilestoneProgression } =
 | 
			
		||||
        useMilestoneProgressionsApi();
 | 
			
		||||
    const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
 | 
			
		||||
    const { setToastData, setToastApiError } = useToast();
 | 
			
		||||
 | 
			
		||||
    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 handleCreateProgression = async (
 | 
			
		||||
        payload: CreateMilestoneProgressionSchema,
 | 
			
		||||
    ) => {
 | 
			
		||||
        if (isChangeRequestConfigured(environment)) {
 | 
			
		||||
            onAddToChangeRequest({
 | 
			
		||||
                type: 'createMilestoneProgression',
 | 
			
		||||
                payload,
 | 
			
		||||
            });
 | 
			
		||||
            handleCloseProgressionForm();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await createMilestoneProgression(
 | 
			
		||||
                projectId,
 | 
			
		||||
                environment,
 | 
			
		||||
                featureName,
 | 
			
		||||
                payload,
 | 
			
		||||
            );
 | 
			
		||||
            setToastData({
 | 
			
		||||
                type: 'success',
 | 
			
		||||
                text: 'Automation configured successfully',
 | 
			
		||||
            });
 | 
			
		||||
            handleCloseProgressionForm();
 | 
			
		||||
            await onUpdate();
 | 
			
		||||
        } catch (error: unknown) {
 | 
			
		||||
            setToastApiError(formatUnknownError(error));
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleUpdateProgression = async (
 | 
			
		||||
        payload: UpdateMilestoneProgressionSchema,
 | 
			
		||||
    ): Promise<{ shouldReset?: boolean }> => {
 | 
			
		||||
        if (isChangeRequestConfigured(environment)) {
 | 
			
		||||
            onAddToChangeRequest({
 | 
			
		||||
                type: 'updateMilestoneProgression',
 | 
			
		||||
                sourceMilestoneId: milestone.id,
 | 
			
		||||
                payload,
 | 
			
		||||
            });
 | 
			
		||||
            return { shouldReset: true };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await updateMilestoneProgression(
 | 
			
		||||
                projectId,
 | 
			
		||||
                environment,
 | 
			
		||||
                featureName,
 | 
			
		||||
                milestone.id,
 | 
			
		||||
                payload,
 | 
			
		||||
            );
 | 
			
		||||
            setToastData({
 | 
			
		||||
                type: 'success',
 | 
			
		||||
                text: 'Automation updated successfully',
 | 
			
		||||
            });
 | 
			
		||||
            await onUpdate();
 | 
			
		||||
            return {};
 | 
			
		||||
        } catch (error: unknown) {
 | 
			
		||||
            setToastApiError(formatUnknownError(error));
 | 
			
		||||
            return {};
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const status = calculateMilestoneStatus(
 | 
			
		||||
        milestone,
 | 
			
		||||
        activeMilestoneId,
 | 
			
		||||
        index,
 | 
			
		||||
        activeIndex,
 | 
			
		||||
        environmentIsDisabled,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const { pendingProgressionChange, effectiveTransitionCondition } =
 | 
			
		||||
        usePendingProgressionChanges(milestone, getPendingProgressionChange);
 | 
			
		||||
 | 
			
		||||
    const shouldShowAutomation =
 | 
			
		||||
        isNotLastMilestone && milestoneProgressionsEnabled;
 | 
			
		||||
 | 
			
		||||
    const automationSection = shouldShowAutomation ? (
 | 
			
		||||
        <MilestoneAutomation
 | 
			
		||||
            milestone={milestone}
 | 
			
		||||
            status={status}
 | 
			
		||||
            isNotLastMilestone={isNotLastMilestone}
 | 
			
		||||
            nextMilestoneId={nextMilestoneId}
 | 
			
		||||
            milestoneProgressionsEnabled={milestoneProgressionsEnabled}
 | 
			
		||||
            readonly={readonly}
 | 
			
		||||
            isProgressionFormOpen={isProgressionFormOpen}
 | 
			
		||||
            effectiveTransitionCondition={effectiveTransitionCondition}
 | 
			
		||||
            pendingProgressionChange={pendingProgressionChange}
 | 
			
		||||
            onOpenProgressionForm={handleOpenProgressionForm}
 | 
			
		||||
            onCloseProgressionForm={handleCloseProgressionForm}
 | 
			
		||||
            onCreateProgression={handleCreateProgression}
 | 
			
		||||
            onUpdateProgression={handleUpdateProgression}
 | 
			
		||||
            onDeleteProgression={onDeleteProgression}
 | 
			
		||||
        />
 | 
			
		||||
    ) : 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>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,20 @@
 | 
			
		||||
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
 | 
			
		||||
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
 | 
			
		||||
 | 
			
		||||
export const calculateMilestoneStatus = (
 | 
			
		||||
    milestone: IReleasePlanMilestone,
 | 
			
		||||
    activeMilestoneId: string | undefined,
 | 
			
		||||
    index: number,
 | 
			
		||||
    activeIndex: number,
 | 
			
		||||
    environmentIsDisabled: boolean | undefined,
 | 
			
		||||
): MilestoneStatus => {
 | 
			
		||||
    if (milestone.id === activeMilestoneId) {
 | 
			
		||||
        return environmentIsDisabled ? 'paused' : 'active';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (index < activeIndex) {
 | 
			
		||||
        return 'completed';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return 'not-started';
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,33 @@
 | 
			
		||||
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
 | 
			
		||||
import type {
 | 
			
		||||
    IReleasePlanMilestoneItemProps,
 | 
			
		||||
    PendingProgressionChange,
 | 
			
		||||
} from './ReleasePlanMilestoneItem.jsx';
 | 
			
		||||
 | 
			
		||||
interface PendingProgressionChangeResult {
 | 
			
		||||
    pendingProgressionChange: PendingProgressionChange | null;
 | 
			
		||||
    effectiveTransitionCondition: IReleasePlanMilestone['transitionCondition'];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const usePendingProgressionChanges = (
 | 
			
		||||
    milestone: IReleasePlanMilestone,
 | 
			
		||||
    getPendingProgressionChange: IReleasePlanMilestoneItemProps['getPendingProgressionChange'],
 | 
			
		||||
): PendingProgressionChangeResult => {
 | 
			
		||||
    const pendingProgressionChange = getPendingProgressionChange(milestone.id);
 | 
			
		||||
 | 
			
		||||
    // 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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        pendingProgressionChange,
 | 
			
		||||
        effectiveTransitionCondition,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user