1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

feat: inital implementation

This commit is contained in:
FredrikOseberg 2025-10-21 12:52:49 +02:00
parent 0fda3e7cf1
commit 244f6fbd43
No known key found for this signature in database
GPG Key ID: 282FD8A6D8F9BCF0
8 changed files with 860 additions and 48 deletions

View File

@ -77,6 +77,7 @@ export const ChangeRequest: VFC<IChangeRequestProps> = ({
change={change}
feature={feature}
onNavigate={onNavigate}
onRefetch={onRefetch}
/>
))}
{feature.defaultChange ? (

View File

@ -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>

View File

@ -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 (
<ReleasePlanProvider getPendingProgressionChange={getPendingProgressionChange}>
<StyledTabs>
<ChangeItemWrapper>
<ChangeItemInfo>
<Added>Adding automation to release plan</Added>
<Typography component='span'>
{sourceMilestoneName} {targetMilestoneName}
</Typography>
</ChangeItemInfo>
<div>
<TabList>
<Tab>View change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
</div>
</ChangeItemWrapper>
<TabPanel>
{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 (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={changeRequestState === 'Applied' || changeRequestState === 'Cancelled'}
milestone={milestone}
showAutomation={showAutomation}
projectId={projectId}
environment={environmentName}
featureName={featureName}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={onUpdateChangeRequestSubmit}
onDeleteAutomation={
showAutomation && onDeleteChangeRequestSubmit
? () => onDeleteChangeRequestSubmit(milestone.id)
: undefined
}
allMilestones={modifiedPlan.milestones}
activeMilestoneId={modifiedPlan.activeMilestoneId}
/>
{isNotLastMilestone && <StyledConnection />}
</div>
);
})}
</TabPanel>
<TabPanel variant='diff'>
<EventDiff
entry={{
preData: previousMilestone,
data: newMilestone,
}}
/>
</TabPanel>
</StyledTabs>
</ReleasePlanProvider>
);
};
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 (
<ReleasePlanProvider getPendingProgressionChange={getPendingProgressionChange}>
<StyledTabs>
<ChangeItemWrapper>
<ChangeItemInfo>
<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>
{modifiedPlan.milestones.map((milestone, index) => {
const isNotLastMilestone = index < modifiedPlan.milestones.length - 1;
const showAutomation = milestone.id === sourceId && isNotLastMilestone && Boolean(milestone.transitionCondition);
return (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={changeRequestState === 'Applied' || changeRequestState === 'Cancelled'}
milestone={milestone}
showAutomation={showAutomation}
projectId={projectId}
environment={environmentName}
featureName={featureName}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={onUpdateChangeRequestSubmit}
onDeleteAutomation={
showAutomation && onDeleteChangeRequestSubmit
? () => onDeleteChangeRequestSubmit(milestone.id)
: undefined
}
allMilestones={modifiedPlan.milestones}
activeMilestoneId={modifiedPlan.activeMilestoneId}
/>
{isNotLastMilestone && <StyledConnection />}
</div>
);
})}
</TabPanel>
<TabPanel variant='diff'>
<EventDiff
entry={{
preData: previousMilestone,
data: newMilestone,
}}
/>
</TabPanel>
</StyledTabs>
</ReleasePlanProvider>
);
};
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 (
<Alert severity="error">
Unable to load release plan data. Please refresh the page.
</Alert>
);
}
// 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 (
<ReleasePlanProvider getPendingProgressionChange={getPendingProgressionChange}>
<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>
{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 (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={changeRequestState === 'Applied' || changeRequestState === 'Cancelled'}
milestone={displayMilestone}
showAutomation={showAutomation}
projectId={projectId}
environment={environmentName}
featureName={featureName}
onUpdate={onUpdate}
onUpdateChangeRequestSubmit={onUpdateChangeRequestSubmit}
onDeleteAutomation={
showAutomation && onDeleteChangeRequestSubmit
? () => onDeleteChangeRequestSubmit(displayMilestone.id)
: undefined
}
allMilestones={modifiedPlan.milestones}
activeMilestoneId={modifiedPlan.activeMilestoneId}
/>
{isNotLastMilestone && <StyledConnection />}
</div>
);
})}
</TabPanel>
<TabPanel variant='diff'>
<EventDiff
entry={{
preData: basePlan,
data: modifiedPlan,
}}
/>
</TabPanel>
</StyledTabs>
</ReleasePlanProvider>
);
};
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 (
<ConsolidatedProgressionChanges
feature={feature}
currentReleasePlan={currentReleasePlan}
projectId={projectId}
environmentName={environmentName}
featureName={featureName}
changeRequestState={changeRequestState}
onUpdate={handleUpdate}
onUpdateChangeRequestSubmit={handleUpdateChangeRequestSubmit}
onDeleteChangeRequestSubmit={handleDeleteChangeRequestSubmit}
/>
);
}
return (
<>
@ -280,6 +911,34 @@ export const ReleasePlanChange: FC<{
actions={actions}
/>
)}
{change.action === 'createMilestoneProgression' && (
<CreateMilestoneProgression
change={change}
currentReleasePlan={currentReleasePlan}
actions={actions}
projectId={projectId}
environmentName={environmentName}
featureName={featureName}
changeRequestState={changeRequestState}
onUpdate={handleUpdate}
onUpdateChangeRequestSubmit={handleUpdateChangeRequestSubmit}
onDeleteChangeRequestSubmit={handleDeleteChangeRequestSubmit}
/>
)}
{change.action === 'updateMilestoneProgression' && (
<UpdateMilestoneProgression
change={change}
currentReleasePlan={currentReleasePlan}
actions={actions}
projectId={projectId}
environmentName={environmentName}
featureName={featureName}
changeRequestState={changeRequestState}
onUpdate={handleUpdate}
onUpdateChangeRequestSubmit={handleUpdateChangeRequestSubmit}
onDeleteChangeRequestSubmit={handleDeleteChangeRequestSubmit}
/>
)}
</>
);
};

View File

@ -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';

View File

@ -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 (
<StyledContainer>
<StyledHeader>
<StyledHeaderGroup>
<StyledHeaderTitleLabel>
Release plan:{' '}
</StyledHeaderTitleLabel>
<StyledHeaderTitle>{name}</StyledHeaderTitle>
<StyledHeaderDescription>
<Truncator lines={2} title={description}>
{description}
</Truncator>
</StyledHeaderDescription>
</StyledHeaderGroup>
{!readonly && (
<PermissionIconButton
onClick={confirmRemoveReleasePlan}
permission={DELETE_FEATURE_STRATEGY}
environmentId={environment}
projectId={projectId}
tooltipProps={{
title: 'Remove release plan',
}}
>
<Delete />
</PermissionIconButton>
)}
</StyledHeader>
<StyledBody>
<ReleasePlanProvider
getPendingProgressionChange={getPendingProgressionChange}
>
<StyledContainer>
<StyledHeader>
<StyledHeaderGroup>
<StyledHeaderTitleLabel>
Release plan:{' '}
</StyledHeaderTitleLabel>
<StyledHeaderTitle>{name}</StyledHeaderTitle>
<StyledHeaderDescription>
<Truncator lines={2} title={description}>
{description}
</Truncator>
</StyledHeaderDescription>
</StyledHeaderGroup>
{!readonly && (
<PermissionIconButton
onClick={confirmRemoveReleasePlan}
permission={DELETE_FEATURE_STRATEGY}
environmentId={environment}
projectId={projectId}
tooltipProps={{
title: 'Remove release plan',
}}
>
<Delete />
</PermissionIconButton>
)}
</StyledHeader>
<StyledBody>
{milestones.map((milestone, index) => {
const isNotLastMilestone = index < milestones.length - 1;
const isProgressionFormOpen =
@ -493,5 +530,6 @@ export const ReleasePlan = ({
/>
)}
</StyledContainer>
</ReleasePlanProvider>
);
};

View File

@ -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<ReleasePlanContextType | null>(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 (
<ReleasePlanContext.Provider value={{ getPendingProgressionChange }}>
{children}
</ReleasePlanContext.Provider>
);
};

View File

@ -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 (
<StyledAutomationContainer status={status}>
{automationForm ? (
automationForm
) : transitionCondition ? (
) : effectiveTransitionCondition ? (
<MilestoneTransitionDisplay
intervalMinutes={transitionCondition.intervalMinutes}
intervalMinutes={effectiveTransitionCondition.intervalMinutes}
onDelete={onDeleteAutomation!}
milestoneName={milestoneName}
status={status}
@ -107,13 +125,20 @@ export const MilestoneAutomationSection = ({
onChangeRequestSubmit={onUpdateChangeRequestSubmit}
/>
) : (
<StyledAddAutomationButton
onClick={onAddAutomation}
color='primary'
startIcon={<Add />}
>
Add automation
</StyledAddAutomationButton>
<StyledAddAutomationContainer>
<StyledAddAutomationButton
onClick={onAddAutomation}
color='primary'
startIcon={<Add />}
>
Add automation
</StyledAddAutomationButton>
{hasPendingCreate && (
<Badge color='warning'>
Modified in draft
</Badge>
)}
</StyledAddAutomationContainer>
)}
</StyledAutomationContainer>
);

View File

@ -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'}
</Button>
)}
{showDraftBadge && (
<Badge color={hasPendingDelete ? 'error' : 'warning'}>
{hasPendingDelete ? 'Deleted in draft' : 'Modified in draft'}
</Badge>
)}
<IconButton
onClick={onDelete}
size='small'