import { useRef, useState, type FC, type ReactNode } from 'react'; import { Alert, styled, Typography } from '@mui/material'; import type { ChangeRequestState, IChangeRequestAddReleasePlan, IChangeRequestDeleteReleasePlan, IChangeRequestStartMilestone, IChangeRequestCreateMilestoneProgression, IChangeRequestUpdateMilestoneProgression, IChangeRequestDeleteMilestoneProgression, IChangeRequestFeature, } from 'component/changeRequest/changeRequest.types'; import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview'; import { useFeatureReleasePlans } from 'hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import { EventDiff } from 'component/events/EventDiff/EventDiff'; import { ReleasePlan } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan'; import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone'; import type { IReleasePlan } from 'interfaces/releasePlans'; import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx'; import { Action, Added, ChangeItemInfo, 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 { 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'; const StyledTabs = styled(Tabs)(({ theme }) => ({ display: 'flex', flexFlow: 'column', gap: theme.spacing(1), })); const StyledConnection = styled('div')(({ theme }) => ({ width: 2, height: theme.spacing(2), backgroundColor: theme.palette.divider, marginLeft: theme.spacing(3.25), })); const DeleteReleasePlan: FC<{ change: IChangeRequestDeleteReleasePlan; currentReleasePlan?: IReleasePlan; changeRequestState: ChangeRequestState; actions?: ReactNode; }> = ({ change, currentReleasePlan, changeRequestState, actions }) => { const releasePlan = changeRequestState === 'Applied' && change.payload.snapshot ? change.payload.snapshot : currentReleasePlan; if (!releasePlan) return; return ( <> Deleting release plan {releasePlan.name} {actions} ); }; const StartMilestone: FC<{ change: IChangeRequestStartMilestone; currentReleasePlan?: IReleasePlan; changeRequestState: ChangeRequestState; actions?: ReactNode; }> = ({ change, currentReleasePlan, changeRequestState, actions }) => { const releasePlan = changeRequestState === 'Applied' && change.payload.snapshot ? change.payload.snapshot : currentReleasePlan; if (!releasePlan) return; const previousMilestone = releasePlan.milestones.find( (milestone) => milestone.id === releasePlan.activeMilestoneId, ); const newMilestone = releasePlan.milestones.find( (milestone) => milestone.id === change.payload.milestoneId, ); if (!newMilestone) return; return ( Start milestone {newMilestone.name}
View change View diff {actions}
); }; const AddReleasePlan: FC<{ change: IChangeRequestAddReleasePlan; currentReleasePlan?: IReleasePlan; environmentName: string; featureName: string; actions?: ReactNode; }> = ({ change, currentReleasePlan, environmentName, featureName, actions, }) => { const [currentTooltipOpen, setCurrentTooltipOpen] = useState(false); const currentTooltipCloseTimeoutRef = useRef(); const openCurrentTooltip = () => { if (currentTooltipCloseTimeoutRef.current) { clearTimeout(currentTooltipCloseTimeoutRef.current); } setCurrentTooltipOpen(true); }; const closeCurrentTooltip = () => { currentTooltipCloseTimeoutRef.current = setTimeout(() => { setCurrentTooltipOpen(false); }, 100); }; const planPreview = useReleasePlanPreview( change.payload.templateId, featureName, environmentName, ); const planPreviewDiff = { ...planPreview, discriminator: 'plan', releasePlanTemplateId: change.payload.templateId, }; if (!currentReleasePlan) { return ( <> Adding release plan {planPreview.name} {actions} ); } return ( Replacing{' '} openCurrentTooltip()} onMouseLeave={() => closeCurrentTooltip()} > } tooltipProps={{ open: currentTooltipOpen, maxWidth: 500, maxHeight: 600, }} > openCurrentTooltip()} onMouseLeave={() => closeCurrentTooltip()} > current {' '} release plan with {planPreview.name}
View change View diff {actions}
); }; const CreateMilestoneProgression: FC<{ change: IChangeRequestCreateMilestoneProgression; currentReleasePlan?: IReleasePlan; actions?: ReactNode; projectId: string; environmentName: string; featureName: string; changeRequestState: ChangeRequestState; onUpdate?: () => void; onUpdateChangeRequestSubmit?: ( sourceMilestoneId: string, payload: UpdateMilestoneProgressionSchema, ) => void; onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => void; }> = ({ change, currentReleasePlan, actions, projectId, environmentName, featureName, changeRequestState, onUpdate, onUpdateChangeRequestSubmit, onDeleteChangeRequestSubmit, }) => { // Use snapshot if available (for Applied state) or if the change has a snapshot const basePlan = change.payload.snapshot || currentReleasePlan; if (!basePlan) return null; // Create a modified release plan with the progression added const modifiedPlan: IReleasePlan = { ...basePlan, milestones: basePlan.milestones.map((milestone) => { if (milestone.id === change.payload.sourceMilestone) { return { ...milestone, transitionCondition: change.payload.transitionCondition, }; } return milestone; }), }; const sourceMilestone = basePlan.milestones.find( (milestone) => milestone.id === change.payload.sourceMilestone, ); const sourceMilestoneName = sourceMilestone?.name || change.payload.sourceMilestone; const targetMilestoneName = basePlan.milestones.find( (milestone) => milestone.id === change.payload.targetMilestone, )?.name || change.payload.targetMilestone; // Get the milestone before and after for diff const previousMilestone = sourceMilestone; const newMilestone = modifiedPlan.milestones.find( (milestone) => milestone.id === change.payload.sourceMilestone, ); return ( Adding automation to release plan {sourceMilestoneName} → {targetMilestoneName}
View change View diff {actions}
{modifiedPlan.milestones.map((milestone, index) => { const isNotLastMilestone = index < modifiedPlan.milestones.length - 1; const isTargetMilestone = milestone.id === change.payload.sourceMilestone; const hasProgression = Boolean(milestone.transitionCondition); const showAutomation = isTargetMilestone && isNotLastMilestone && hasProgression; const readonly = changeRequestState === 'Applied' || changeRequestState === 'Cancelled'; const status: MilestoneStatus = 'not-started'; // In change request view, always not-started // Build automation section for this milestone const automationSection = showAutomation && milestone.transitionCondition ? ( { onUpdateChangeRequestSubmit?.(milestone.id, payload); }} onDelete={() => onDeleteChangeRequestSubmit?.(milestone.id)} milestoneName={milestone.name} status={status} hasPendingUpdate={false} hasPendingDelete={false} /> ) : undefined; return (
{isNotLastMilestone && }
); })}
); }; const UpdateMilestoneProgression: FC<{ change: IChangeRequestUpdateMilestoneProgression; currentReleasePlan?: IReleasePlan; actions?: ReactNode; projectId: string; environmentName: string; featureName: string; changeRequestState: ChangeRequestState; onUpdate?: () => void; onUpdateChangeRequestSubmit?: ( sourceMilestoneId: string, payload: UpdateMilestoneProgressionSchema, ) => void; onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => void; }> = ({ change, currentReleasePlan, actions, projectId, environmentName, featureName, changeRequestState, onUpdate, onUpdateChangeRequestSubmit, onDeleteChangeRequestSubmit, }) => { // Use snapshot if available (for Applied state) or if the change has a snapshot const basePlan = change.payload.snapshot || currentReleasePlan; if (!basePlan) return null; const sourceId = change.payload.sourceMilestoneId || change.payload.sourceMilestone; const sourceMilestone = basePlan.milestones.find( (milestone) => milestone.id === sourceId, ); const sourceMilestoneName = sourceMilestone?.name || sourceId; // Create a modified release plan with the updated progression const modifiedPlan: IReleasePlan = { ...basePlan, milestones: basePlan.milestones.map((milestone) => { if (milestone.id === sourceId) { return { ...milestone, transitionCondition: change.payload.transitionCondition, }; } return milestone; }), }; // Get the milestone before and after for diff const previousMilestone = sourceMilestone; const newMilestone = modifiedPlan.milestones.find( (milestone) => milestone.id === change.payload.sourceMilestoneId, ); return ( Updating automation in release plan {sourceMilestoneName}
View change View diff {actions}
{modifiedPlan.milestones.map((milestone, index) => { const isNotLastMilestone = index < modifiedPlan.milestones.length - 1; const showAutomation = milestone.id === sourceId && isNotLastMilestone && Boolean(milestone.transitionCondition); const readonly = changeRequestState === 'Applied' || changeRequestState === 'Cancelled'; const status: MilestoneStatus = 'not-started'; // Build automation section for this milestone const automationSection = showAutomation && milestone.transitionCondition ? ( { onUpdateChangeRequestSubmit?.(milestone.id, payload); }} onDelete={() => onDeleteChangeRequestSubmit?.(milestone.id)} milestoneName={milestone.name} status={status} hasPendingUpdate={false} hasPendingDelete={false} /> ) : undefined; return (
{isNotLastMilestone && }
); })}
); }; const ConsolidatedProgressionChanges: FC<{ feature: IChangeRequestFeature; currentReleasePlan?: IReleasePlan; projectId: string; environmentName: string; featureName: string; changeRequestState: ChangeRequestState; onUpdate?: () => void; onUpdateChangeRequestSubmit?: ( sourceMilestoneId: string, payload: UpdateMilestoneProgressionSchema, ) => void; onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => void; }> = ({ feature, currentReleasePlan, projectId, environmentName, featureName, changeRequestState, onUpdate, 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; // Use snapshot from first change if available, otherwise use current release plan // Prioritize create/update changes over delete changes for snapshot selection const firstChangeWithSnapshot = progressionChanges.find((change) => change.payload?.snapshot && (change.action === 'createMilestoneProgression' || change.action === 'updateMilestoneProgression') ) || progressionChanges.find((change) => change.payload?.snapshot); const basePlan = firstChangeWithSnapshot?.payload?.snapshot || currentReleasePlan; if (!basePlan) { console.error('[ConsolidatedProgressionChanges] No release plan data available', { hasSnapshot: !!firstChangeWithSnapshot, hasCurrentPlan: !!currentReleasePlan, progressionChanges }); return ( Unable to load release plan data. Please refresh the page. ); } // Apply all progression changes to the release plan const modifiedPlan: IReleasePlan = { ...basePlan, milestones: basePlan.milestones.map((milestone) => { // Find if there's a progression change for this 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.sourceMilestoneId === milestone.id || change.payload.sourceMilestone === milestone.id), ); const deleteChange = progressionChanges.find( (change): change is IChangeRequestDeleteMilestoneProgression => change.action === 'deleteMilestoneProgression' && (change.payload.sourceMilestoneId === milestone.id || change.payload.sourceMilestone === milestone.id), ); // Check for conflicting changes (delete + create/update for same milestone) if (deleteChange && (createChange || updateChange)) { console.warn('[ConsolidatedProgressionChanges] Conflicting changes detected for milestone:', { milestone: milestone.name, hasCreate: !!createChange, hasUpdate: !!updateChange, hasDelete: !!deleteChange }); } // If there's a delete change, remove the transition condition // Delete takes precedence over create/update if (deleteChange) { return { ...milestone, transitionCondition: null, }; } const change = updateChange || createChange; if (change) { return { ...milestone, transitionCondition: change.payload.transitionCondition, }; } return milestone; }), }; const changeDescriptions = progressionChanges.map((change) => { const sourceId = change.action === 'createMilestoneProgression' ? change.payload.sourceMilestone : (change.payload.sourceMilestoneId || 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}`; }); return ( {progressionChanges.map((change, index) => { const Component = change.action === 'deleteMilestoneProgression' ? Deleted : Added; return ( {changeDescriptions[index]} ); })}
View change View diff
{modifiedPlan.milestones.map((milestone, index) => { const isNotLastMilestone = index < modifiedPlan.milestones.length - 1; // Check if there's a delete change for this milestone const deleteChange = progressionChanges.find( (change): change is IChangeRequestDeleteMilestoneProgression => change.action === 'deleteMilestoneProgression' && (change.payload.sourceMilestoneId === milestone.id || change.payload.sourceMilestone === milestone.id), ); // If there's a delete change, use the original milestone from basePlan const originalMilestone = deleteChange ? basePlan.milestones.find(baseMilestone => baseMilestone.id === milestone.id) : null; const displayMilestone = deleteChange && originalMilestone ? originalMilestone : milestone; // Show automation section for any milestone that has a transition condition // or if there's a delete change (to show what's being deleted) const shouldShowAutomationSection = Boolean(displayMilestone.transitionCondition) || Boolean(deleteChange); const showAutomation = isNotLastMilestone && shouldShowAutomationSection; const readonly = changeRequestState === 'Applied' || changeRequestState === 'Cancelled'; const status: MilestoneStatus = 'not-started'; // Build automation section for this milestone const automationSection = showAutomation && displayMilestone.transitionCondition ? ( { onUpdateChangeRequestSubmit?.(displayMilestone.id, payload); }} onDelete={() => onDeleteChangeRequestSubmit?.(displayMilestone.id)} milestoneName={displayMilestone.name} status={status} hasPendingUpdate={false} hasPendingDelete={Boolean(deleteChange)} /> ) : undefined; return (
{isNotLastMilestone && }
); })}
); }; export const ReleasePlanChange: FC<{ actions?: ReactNode; change: | IChangeRequestAddReleasePlan | IChangeRequestDeleteReleasePlan | 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, featureName, environmentName, projectId, changeRequestState, feature, onRefetch, }) => { const { releasePlans, refetch } = useFeatureReleasePlans( projectId, featureName, environmentName, ); const currentReleasePlan = releasePlans[0]; const { addChange } = useChangeRequestApi(); const { refetch: refetchChangeRequests } = usePendingChangeRequests(projectId); const { setToastData } = useToast(); const handleUpdate = async () => { await refetch(); if (onRefetch) { await onRefetch(); } }; 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 ( ); } return ( <> {change.action === 'addReleasePlan' && ( )} {change.action === 'deleteReleasePlan' && ( )} {change.action === 'startMilestone' && ( )} {change.action === 'createMilestoneProgression' && ( )} {change.action === 'updateMilestoneProgression' && ( )} ); };