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

feat: instant milestone progression prevention (#10879)

This commit is contained in:
Mateusz Kwasniewski 2025-10-28 13:13:23 +01:00 committed by GitHub
parent 2f315545a3
commit 2823c94a38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 71 additions and 9 deletions

View File

@ -70,6 +70,7 @@ const MilestoneListRendererCore = ({
.intervalMinutes
}
targetMilestoneId={nextMilestoneId}
sourceMilestoneStartedAt={milestone.startedAt}
onSave={async (payload) => {
await onUpdateAutomation(
milestone.id,

View File

@ -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}
/>
</StyledTopRow>
{form.errors.time && (
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
)}
<StyledButtonGroup>
{form.errors.time && (
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
)}
<Button variant='outlined' onClick={onCancel} size='small'>
Cancel
</Button>

View File

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

View File

@ -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 = ({
</StyledButtonGroup>
)}
</StyledDisplayContainer>
{form.errors.time && (
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
)}
{hasChanged && (
<StyledButtonGroup hasChanged={true}>
<Button

View File

@ -93,6 +93,8 @@ export const MilestoneAutomation = ({
<MilestoneProgressionForm
sourceMilestoneId={milestone.id}
targetMilestoneId={nextMilestoneId}
sourceMilestoneStartedAt={milestone.startedAt}
status={status}
onSubmit={onChangeProgression}
onCancel={onCloseProgressionForm}
/>
@ -102,6 +104,7 @@ export const MilestoneAutomation = ({
effectiveTransitionCondition.intervalMinutes
}
targetMilestoneId={nextMilestoneId}
sourceMilestoneStartedAt={milestone.startedAt}
onSave={onChangeProgression}
onDelete={() => onDeleteProgression(milestone)}
milestoneName={milestone.name}

View File

@ -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<TimeUnit>(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,