1
0
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:
Fredrik Strand Oseberg 2025-10-22 12:27:24 +02:00 committed by GitHub
parent b9d81e5f59
commit 866441a1b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1145 additions and 399 deletions

View File

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

View File

@ -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>
);
};

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

@ -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>
);
})}
</>
);
};

View File

@ -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>
);
};

View File

@ -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
}
/>
)}
</>
);
};

View File

@ -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;
}),
};
};

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

View File

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

View File

@ -13,8 +13,6 @@ import type {
import { useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ReleasePlanRemoveDialog } from './ReleasePlanRemoveDialog.tsx';
import { ReleasePlanMilestone } from './ReleasePlanMilestone/ReleasePlanMilestone.tsx';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
@ -22,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}

View File

@ -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>
);
};

View File

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

View File

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

View File

@ -0,0 +1,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>
);
};

View File

@ -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>
);
};

View File

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

View File

@ -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,
};
};