diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx
index b2167f3b71..0ba98d7c46 100644
--- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx
+++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx
@@ -70,6 +70,7 @@ const MilestoneListRendererCore = ({
.intervalMinutes
}
targetMilestoneId={nextMilestoneId}
+ sourceMilestoneStartedAt={milestone.startedAt}
onSave={async (payload) => {
await onUpdateAutomation(
milestone.id,
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx
index 3e11b58f40..8f17fd52df 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx
@@ -3,6 +3,7 @@ import BoltIcon from '@mui/icons-material/Bolt';
import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js';
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
import type { ChangeMilestoneProgressionSchema } from 'openapi';
+import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
const StyledFormContainer = styled('div')(({ theme }) => ({
display: 'flex',
@@ -49,12 +50,14 @@ const StyledButtonGroup = styled('div')(({ theme }) => ({
const StyledErrorMessage = styled('span')(({ theme }) => ({
color: theme.palette.error.main,
fontSize: theme.typography.body2.fontSize,
- marginRight: 'auto',
+ paddingLeft: theme.spacing(3.25),
}));
interface IMilestoneProgressionFormProps {
sourceMilestoneId: string;
targetMilestoneId: string;
+ sourceMilestoneStartedAt?: string | null;
+ status?: MilestoneStatus;
onSubmit: (
payload: ChangeMilestoneProgressionSchema,
) => Promise<{ shouldReset?: boolean }>;
@@ -64,12 +67,17 @@ interface IMilestoneProgressionFormProps {
export const MilestoneProgressionForm = ({
sourceMilestoneId,
targetMilestoneId,
+ sourceMilestoneStartedAt,
+ status,
onSubmit,
onCancel,
}: IMilestoneProgressionFormProps) => {
const form = useMilestoneProgressionForm(
sourceMilestoneId,
targetMilestoneId,
+ {},
+ sourceMilestoneStartedAt,
+ status,
);
const handleSubmit = async () => {
@@ -102,10 +110,10 @@ export const MilestoneProgressionForm = ({
onTimeUnitChange={form.handleTimeUnitChange}
/>
+ {form.errors.time && (
+ {form.errors.time}
+ )}
- {form.errors.time && (
- {form.errors.time}
- )}
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneNextStartTime.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneNextStartTime.tsx
index 9beaf268fd..fe405bb91b 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneNextStartTime.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneNextStartTime.tsx
@@ -1,12 +1,12 @@
import { styled } from '@mui/material';
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
-import { formatDateYMDHMS } from 'utils/formatDate';
+import { formatDateYMDHM } from 'utils/formatDate';
import { isToday, isTomorrow, format } from 'date-fns';
import { calculateMilestoneStartTime } from '../utils/calculateMilestoneStartTime.ts';
import { useUiFlag } from 'hooks/useUiFlag';
-const formatSmartDate = (date: Date): string => {
+export const formatSmartDate = (date: Date): string => {
const timeString = format(date, 'HH:mm');
if (isToday(date)) {
@@ -17,7 +17,7 @@ const formatSmartDate = (date: Date): string => {
}
// For other dates, show full date with time
- return formatDateYMDHMS(date);
+ return formatDateYMDHM(date);
};
const StyledTimeContainer = styled('span')(({ theme }) => ({
@@ -74,7 +74,7 @@ export const MilestoneNextStartTime = ({
);
const text = projectedStartTime
- ? `Starting ${formatSmartDate(projectedStartTime)}`
+ ? `Starting after ${formatSmartDate(projectedStartTime)}`
: 'Waiting to start';
return (
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx
index 2afed24fdc..1bfe825e50 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx
@@ -90,9 +90,16 @@ const StyledButtonGroup = styled('div', {
}),
}));
+const StyledErrorMessage = styled('span')(({ theme }) => ({
+ color: theme.palette.error.main,
+ fontSize: theme.typography.body2.fontSize,
+ paddingLeft: theme.spacing(3.25),
+}));
+
interface IMilestoneTransitionDisplayProps {
intervalMinutes: number;
targetMilestoneId: string;
+ sourceMilestoneStartedAt?: string | null;
onSave: (
payload: ChangeMilestoneProgressionSchema,
) => Promise<{ shouldReset?: boolean }>;
@@ -105,6 +112,7 @@ interface IMilestoneTransitionDisplayProps {
export const MilestoneTransitionDisplay = ({
intervalMinutes,
targetMilestoneId,
+ sourceMilestoneStartedAt,
onSave,
onDelete,
milestoneName,
@@ -119,6 +127,8 @@ export const MilestoneTransitionDisplay = ({
timeValue: initial.value,
timeUnit: initial.unit,
},
+ sourceMilestoneStartedAt,
+ status,
);
const currentIntervalMinutes = form.getIntervalMinutes();
@@ -130,9 +140,19 @@ export const MilestoneTransitionDisplay = ({
form.setTimeUnit(newInitial.unit);
}, [intervalMinutes]);
+ useEffect(() => {
+ if (!hasChanged) {
+ form.clearErrors();
+ }
+ }, [hasChanged, form.clearErrors]);
+
const handleSave = async () => {
if (!hasChanged) return;
+ if (!form.validate()) {
+ return;
+ }
+
const payload: ChangeMilestoneProgressionSchema = {
targetMilestone: targetMilestoneId,
transitionCondition: {
@@ -151,6 +171,7 @@ export const MilestoneTransitionDisplay = ({
const initial = getTimeValueAndUnitFromMinutes(intervalMinutes);
form.setTimeValue(initial.value);
form.setTimeUnit(initial.unit);
+ form.clearErrors();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
@@ -192,6 +213,9 @@ export const MilestoneTransitionDisplay = ({
)}
+ {form.errors.time && (
+ {form.errors.time}
+ )}
{hasChanged && (
@@ -102,6 +104,7 @@ export const MilestoneAutomation = ({
effectiveTransitionCondition.intervalMinutes
}
targetMilestoneId={nextMilestoneId}
+ sourceMilestoneStartedAt={milestone.startedAt}
onSave={onChangeProgression}
onDelete={() => onDeleteProgression(milestone)}
milestoneName={milestone.name}
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts
index 654003b9bb..78729f1290 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts
@@ -1,4 +1,7 @@
-import { useState } from 'react';
+import { useState, useCallback } from 'react';
+import { isPast, addMinutes } from 'date-fns';
+import { formatSmartDate } from '../ReleasePlanMilestone/MilestoneNextStartTime.tsx';
+import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
const MAX_INTERVAL_MINUTES = 525600; // 365 days
@@ -42,6 +45,8 @@ export const useMilestoneProgressionForm = (
timeUnit: initialTimeUnit = 'hours',
timeValue: initialTimeValue = 5,
}: MilestoneProgressionFormDefaults = {},
+ sourceMilestoneStartedAt?: string | null,
+ status?: MilestoneStatus,
) => {
const [timeUnit, setTimeUnit] = useState(initialTimeUnit);
const [timeValue, setTimeValue] = useState(initialTimeValue);
@@ -78,6 +83,22 @@ export const useMilestoneProgressionForm = (
newErrors.time = 'Time interval cannot exceed 365 days';
}
+ // Only validate against current time for active/paused milestones
+ // Completed and not-started milestones shouldn't validate against current time
+ if (
+ sourceMilestoneStartedAt &&
+ total > 0 &&
+ (status === 'active' || status === 'paused')
+ ) {
+ const startDate = new Date(sourceMilestoneStartedAt);
+ const nextMilestoneDate = addMinutes(startDate, total);
+
+ if (isPast(nextMilestoneDate)) {
+ const formattedDate = formatSmartDate(nextMilestoneDate);
+ newErrors.time = `Next milestone can't start in the past (${formattedDate})`;
+ }
+ }
+
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -96,12 +117,17 @@ export const useMilestoneProgressionForm = (
}
};
+ const clearErrors = useCallback(() => {
+ setErrors({});
+ }, []);
+
return {
timeUnit,
setTimeUnit,
timeValue,
setTimeValue,
errors,
+ clearErrors,
validate,
getProgressionPayload,
getIntervalMinutes,