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