From 244f6fbd435e6b9f4bad22f4bef344c8baec2a09 Mon Sep 17 00:00:00 2001 From: FredrikOseberg Date: Tue, 21 Oct 2025 12:52:49 +0200 Subject: [PATCH] feat: inital implementation --- .../ChangeRequest/ChangeRequest.tsx | 1 + .../Changes/Change/FeatureChange.tsx | 9 +- .../Changes/Change/ReleasePlanChange.tsx | 665 +++++++++++++++++- .../changeRequest/changeRequest.types.ts | 29 +- .../ReleasePlan/ReleasePlan.tsx | 98 ++- .../ReleasePlan/ReleasePlanContext.tsx | 45 ++ .../MilestoneAutomationSection.tsx | 43 +- .../MilestoneTransitionDisplay.tsx | 18 + 8 files changed, 860 insertions(+), 48 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanContext.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx b/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx index b7cef5c9a2..cd988c5c11 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx @@ -77,6 +77,7 @@ export const ChangeRequest: VFC = ({ change={change} feature={feature} onNavigate={onNavigate} + onRefetch={onRefetch} /> ))} {feature.defaultChange ? ( diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index d63c05ede5..49ab7fb3cb 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -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') && ( )} diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx index 4f7e4e5746..4bc8dc4971 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx @@ -1,10 +1,14 @@ import { useRef, useState, type FC, type ReactNode } from 'react'; -import { styled, Typography } from '@mui/material'; +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'; @@ -21,6 +25,41 @@ 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 { ReleasePlanProvider } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanContext.tsx'; + +// Indicates that a change is in draft and not yet part of a change request +const PENDING_CHANGE_REQUEST_ID = -1; + +// Helper function to create getPendingProgressionChange for context +const createGetPendingProgressionChange = ( + progressionChanges: (IChangeRequestCreateMilestoneProgression | IChangeRequestUpdateMilestoneProgression | IChangeRequestDeleteMilestoneProgression)[] +) => { + return (sourceMilestoneId: string) => { + const change = progressionChanges.find( + (progressionChange) => + (progressionChange.action === 'updateMilestoneProgression' && + (progressionChange.payload.sourceMilestoneId === sourceMilestoneId || + progressionChange.payload.sourceMilestone === sourceMilestoneId)) || + (progressionChange.action === 'deleteMilestoneProgression' && + (progressionChange.payload.sourceMilestoneId === sourceMilestoneId || + progressionChange.payload.sourceMilestone === sourceMilestoneId)) || + (progressionChange.action === 'createMilestoneProgression' && + progressionChange.payload.sourceMilestone === sourceMilestoneId), + ); + + if (!change) return null; + + return { + action: change.action, + payload: change.payload, + changeRequestId: PENDING_CHANGE_REQUEST_ID, + }; + }; +}; const StyledTabs = styled(Tabs)(({ theme }) => ({ display: 'flex', @@ -28,6 +67,13 @@ const StyledTabs = styled(Tabs)(({ theme }) => ({ 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; @@ -228,16 +274,512 @@ const AddReleasePlan: FC<{ ); }; +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, + ); + + // Create a function to get this specific change for the context + const getPendingProgressionChange = (sourceMilestoneId: string) => { + if (sourceMilestoneId === change.payload.sourceMilestone) { + return { + action: change.action, + payload: change.payload, + changeRequestId: -1, + }; + } + return null; + }; + + 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; + + console.log('[CreateProgression] Milestone:', milestone.name, { + isTargetMilestone, + isNotLastMilestone, + hasProgression, + showAutomation, + transitionCondition: milestone.transitionCondition, + projectId, + environment: environmentName, + featureName, + }); + + return ( +
+ onDeleteChangeRequestSubmit(milestone.id) + : undefined + } + allMilestones={modifiedPlan.milestones} + activeMilestoneId={modifiedPlan.activeMilestoneId} + /> + {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, + ); + + // Create a function to get this specific change for the context + const getPendingProgressionChange = (sourceMilestoneId: string) => { + if (sourceMilestoneId === sourceId) { + return { + action: change.action, + payload: change.payload, + changeRequestId: -1, + }; + } + return null; + }; + + 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); + + return ( +
+ onDeleteChangeRequestSubmit(milestone.id) + : undefined + } + allMilestones={modifiedPlan.milestones} + activeMilestoneId={modifiedPlan.activeMilestoneId} + /> + {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}`; + }); + + // Create a function to get pending progression changes for the context + const getPendingProgressionChange = createGetPendingProgressionChange(progressionChanges); + + 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; + + // Warn if we can't find the original milestone for a delete change + if (deleteChange && !originalMilestone) { + console.error('[ConsolidatedProgressionChanges] Cannot find original milestone for delete', { + milestoneId: milestone.id, + milestoneName: milestone.name, + basePlanMilestones: basePlan.milestones.map(baseMilestone => ({ id: baseMilestone.id, name: baseMilestone.name })) + }); + } + + const displayMilestone = deleteChange && originalMilestone ? originalMilestone : milestone; + + // Show automation section for any milestone that has a transition condition + // or if there's a delete change (to show what's being deleted) + const shouldShowAutomationSection = Boolean(displayMilestone.transitionCondition) || Boolean(deleteChange); + const showAutomation = isNotLastMilestone && shouldShowAutomationSection; + + return ( +
+ onDeleteChangeRequestSubmit(displayMilestone.id) + : undefined + } + allMilestones={modifiedPlan.milestones} + activeMilestoneId={modifiedPlan.activeMilestoneId} + /> + {isNotLastMilestone && } +
+ ); + })} +
+ + + +
+
+ ); +}; + export const ReleasePlanChange: FC<{ actions?: ReactNode; 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, @@ -245,13 +787,102 @@ 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 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 ( <> @@ -280,6 +911,34 @@ export const ReleasePlanChange: FC<{ actions={actions} /> )} + {change.action === 'createMilestoneProgression' && ( + + )} + {change.action === 'updateMilestoneProgression' && ( + + )} ); }; diff --git a/frontend/src/component/changeRequest/changeRequest.types.ts b/frontend/src/component/changeRequest/changeRequest.types.ts index e12f40084c..f77e25276f 100644 --- a/frontend/src/component/changeRequest/changeRequest.types.ts +++ b/frontend/src/component/changeRequest/changeRequest.types.ts @@ -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,23 @@ 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 +352,5 @@ export type ChangeRequestAction = | 'deleteReleasePlan' | 'startMilestone' | 'createMilestoneProgression' - | 'updateMilestoneProgression'; + | 'updateMilestoneProgression' + | 'deleteMilestoneProgression'; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index db220b3f89..9b149eeaf6 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -29,6 +29,7 @@ import type { CreateMilestoneProgressionSchema, UpdateMilestoneProgressionSchema, } from 'openapi'; +import { ReleasePlanProvider } from './ReleasePlanContext.tsx'; const StyledContainer = styled('div')(({ theme }) => ({ padding: theme.spacing(2), @@ -140,8 +141,41 @@ 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 cr of pendingChangeRequests) { + if (cr.environment !== environment) continue; + + const feature = cr.features.find((f) => f.name === featureName); + if (!feature) continue; + + // Look for update or delete progression changes + const change = feature.changes.find( + (c: any) => + (c.action === 'updateMilestoneProgression' && + (c.payload.sourceMilestoneId === sourceMilestoneId || + c.payload.sourceMilestone === sourceMilestoneId)) || + (c.action === 'deleteMilestoneProgression' && + (c.payload.sourceMilestoneId === sourceMilestoneId || + c.payload.sourceMilestone === sourceMilestoneId)), + ); + + if (change) { + return { + action: change.action, + payload: change.payload, + changeRequestId: cr.id, + }; + } + } + + return null; + }; const milestoneProgressionsEnabled = useUiFlag('milestoneProgression'); const [progressionFormOpenIndex, setProgressionFormOpenIndex] = useState< number | null @@ -181,7 +215,6 @@ export const ReleasePlan = ({ action: 'createMilestoneProgression', payload: changeRequestAction.payload, }); - setProgressionFormOpenIndex(null); break; case 'updateMilestoneProgression': @@ -214,6 +247,7 @@ export const ReleasePlan = ({ }); setChangeRequestAction(null); + setProgressionFormOpenIndex(null); }; const confirmRemoveReleasePlan = () => { @@ -364,34 +398,37 @@ export const ReleasePlan = ({ ); return ( - - - - - Release plan:{' '} - - {name} - - - {description} - - - - {!readonly && ( - - - - )} - - + + + + + + Release plan:{' '} + + {name} + + + {description} + + + + {!readonly && ( + + + + )} + + {milestones.map((milestone, index) => { const isNotLastMilestone = index < milestones.length - 1; const isProgressionFormOpen = @@ -493,5 +530,6 @@ export const ReleasePlan = ({ /> )} + ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanContext.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanContext.tsx new file mode 100644 index 0000000000..fb3b6b27e9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanContext.tsx @@ -0,0 +1,45 @@ +import { createContext, useContext, type ReactNode } from 'react'; + +interface PendingProgressionChange { + action: string; + payload: any; + changeRequestId: number; +} + +interface ReleasePlanContextType { + getPendingProgressionChange: ( + sourceMilestoneId: string, + ) => PendingProgressionChange | null; +} + +const ReleasePlanContext = createContext(null); + +export const useReleasePlanContext = () => { + const context = useContext(ReleasePlanContext); + if (!context) { + // Return a fallback context that returns null for all milestone IDs + // This allows the component to work without the provider (e.g., in change request views) + return { + getPendingProgressionChange: () => null, + }; + } + return context; +}; + +interface ReleasePlanProviderProps { + children: ReactNode; + getPendingProgressionChange: ( + sourceMilestoneId: string, + ) => PendingProgressionChange | null; +} + +export const ReleasePlanProvider = ({ + children, + getPendingProgressionChange, +}: ReleasePlanProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneAutomationSection.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneAutomationSection.tsx index 1e13a5b9da..a3f7cecdf4 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneAutomationSection.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneAutomationSection.tsx @@ -3,6 +3,8 @@ import { Button, styled } from '@mui/material'; import type { MilestoneStatus } from './ReleasePlanMilestoneStatus.tsx'; import { MilestoneTransitionDisplay } from './MilestoneTransitionDisplay.tsx'; import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import { Badge } from 'component/common/Badge/Badge'; +import { useReleasePlanContext } from '../ReleasePlanContext.tsx'; const StyledAutomationContainer = styled('div', { shouldForwardProp: (prop) => prop !== 'status', @@ -51,6 +53,12 @@ const StyledAddAutomationButton = styled(Button)(({ theme }) => ({ }, })); +const StyledAddAutomationContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), +})); + interface IMilestoneAutomationSectionProps { showAutomation?: boolean; status?: MilestoneStatus; @@ -87,15 +95,25 @@ export const MilestoneAutomationSection = ({ onUpdate, onUpdateChangeRequestSubmit, }: IMilestoneAutomationSectionProps) => { + const { getPendingProgressionChange } = useReleasePlanContext(); + const pendingProgressionChange = getPendingProgressionChange(sourceMilestoneId); + + const hasPendingCreate = pendingProgressionChange?.action === 'createMilestoneProgression'; + + // For pending create changes, use the transition condition from the pending change + const effectiveTransitionCondition = hasPendingCreate && pendingProgressionChange?.payload?.transitionCondition + ? pendingProgressionChange.payload.transitionCondition + : transitionCondition; + if (!showAutomation) return null; return ( {automationForm ? ( automationForm - ) : transitionCondition ? ( + ) : effectiveTransitionCondition ? ( ) : ( - } - > - Add automation - + + } + > + Add automation + + {hasPendingCreate && ( + + Modified in draft + + )} + )} ); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx index 81aa6709de..e62bacfdea 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx @@ -1,6 +1,7 @@ import BoltIcon from '@mui/icons-material/Bolt'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import { Button, IconButton, styled } from '@mui/material'; +import { Badge } from 'component/common/Badge/Badge'; import type { MilestoneStatus } from './ReleasePlanMilestoneStatus.tsx'; import { useState } from 'react'; import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi'; @@ -13,6 +14,7 @@ import { } from '../hooks/useMilestoneProgressionForm.js'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import { useReleasePlanContext } from '../ReleasePlanContext.tsx'; const StyledDisplayContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -90,6 +92,8 @@ export const MilestoneTransitionDisplay = ({ const { updateMilestoneProgression } = useMilestoneProgressionsApi(); const { setToastData, setToastApiError } = useToast(); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { getPendingProgressionChange } = useReleasePlanContext(); + const pendingProgressionChange = getPendingProgressionChange(sourceMilestoneId); const initial = getTimeValueAndUnitFromMinutes(intervalMinutes); const form = useMilestoneProgressionForm( @@ -105,6 +109,13 @@ export const MilestoneTransitionDisplay = ({ const currentIntervalMinutes = form.getIntervalMinutes(); const hasChanged = currentIntervalMinutes !== intervalMinutes; + // Check if there's a pending change request for this progression + const hasPendingUpdate = + pendingProgressionChange?.action === 'updateMilestoneProgression'; + const hasPendingDelete = + pendingProgressionChange?.action === 'deleteMilestoneProgression'; + const showDraftBadge = hasPendingUpdate || hasPendingDelete; + const handleSave = async () => { if (isSubmitting || !hasChanged) return; @@ -116,6 +127,8 @@ export const MilestoneTransitionDisplay = ({ if (isChangeRequestConfigured(environment) && onChangeRequestSubmit) { onChangeRequestSubmit(sourceMilestoneId, payload); + // Reset the form after submitting to change request + handleReset(); return; } @@ -183,6 +196,11 @@ export const MilestoneTransitionDisplay = ({ {isSubmitting ? 'Saving...' : 'Save'} )} + {showDraftBadge && ( + + {hasPendingDelete ? 'Deleted in draft' : 'Modified in draft'} + + )}