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 .intervalMinutes
} }
targetMilestoneId={nextMilestoneId} targetMilestoneId={nextMilestoneId}
sourceMilestoneStartedAt={milestone.startedAt}
onSave={async (payload) => { onSave={async (payload) => {
await onUpdateAutomation( await onUpdateAutomation(
milestone.id, milestone.id,

View File

@ -3,6 +3,7 @@ import BoltIcon from '@mui/icons-material/Bolt';
import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js'; import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js';
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx'; import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
import type { ChangeMilestoneProgressionSchema } from 'openapi'; import type { ChangeMilestoneProgressionSchema } from 'openapi';
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
const StyledFormContainer = styled('div')(({ theme }) => ({ const StyledFormContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -49,12 +50,14 @@ const StyledButtonGroup = styled('div')(({ theme }) => ({
const StyledErrorMessage = styled('span')(({ theme }) => ({ const StyledErrorMessage = styled('span')(({ theme }) => ({
color: theme.palette.error.main, color: theme.palette.error.main,
fontSize: theme.typography.body2.fontSize, fontSize: theme.typography.body2.fontSize,
marginRight: 'auto', paddingLeft: theme.spacing(3.25),
})); }));
interface IMilestoneProgressionFormProps { interface IMilestoneProgressionFormProps {
sourceMilestoneId: string; sourceMilestoneId: string;
targetMilestoneId: string; targetMilestoneId: string;
sourceMilestoneStartedAt?: string | null;
status?: MilestoneStatus;
onSubmit: ( onSubmit: (
payload: ChangeMilestoneProgressionSchema, payload: ChangeMilestoneProgressionSchema,
) => Promise<{ shouldReset?: boolean }>; ) => Promise<{ shouldReset?: boolean }>;
@ -64,12 +67,17 @@ interface IMilestoneProgressionFormProps {
export const MilestoneProgressionForm = ({ export const MilestoneProgressionForm = ({
sourceMilestoneId, sourceMilestoneId,
targetMilestoneId, targetMilestoneId,
sourceMilestoneStartedAt,
status,
onSubmit, onSubmit,
onCancel, onCancel,
}: IMilestoneProgressionFormProps) => { }: IMilestoneProgressionFormProps) => {
const form = useMilestoneProgressionForm( const form = useMilestoneProgressionForm(
sourceMilestoneId, sourceMilestoneId,
targetMilestoneId, targetMilestoneId,
{},
sourceMilestoneStartedAt,
status,
); );
const handleSubmit = async () => { const handleSubmit = async () => {
@ -102,10 +110,10 @@ export const MilestoneProgressionForm = ({
onTimeUnitChange={form.handleTimeUnitChange} onTimeUnitChange={form.handleTimeUnitChange}
/> />
</StyledTopRow> </StyledTopRow>
<StyledButtonGroup>
{form.errors.time && ( {form.errors.time && (
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage> <StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
)} )}
<StyledButtonGroup>
<Button variant='outlined' onClick={onCancel} size='small'> <Button variant='outlined' onClick={onCancel} size='small'>
Cancel Cancel
</Button> </Button>

View File

@ -1,12 +1,12 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined'; import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
import { formatDateYMDHMS } from 'utils/formatDate'; import { formatDateYMDHM } from 'utils/formatDate';
import { isToday, isTomorrow, format } from 'date-fns'; import { isToday, isTomorrow, format } from 'date-fns';
import { calculateMilestoneStartTime } from '../utils/calculateMilestoneStartTime.ts'; import { calculateMilestoneStartTime } from '../utils/calculateMilestoneStartTime.ts';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
const formatSmartDate = (date: Date): string => { export const formatSmartDate = (date: Date): string => {
const timeString = format(date, 'HH:mm'); const timeString = format(date, 'HH:mm');
if (isToday(date)) { if (isToday(date)) {
@ -17,7 +17,7 @@ const formatSmartDate = (date: Date): string => {
} }
// For other dates, show full date with time // For other dates, show full date with time
return formatDateYMDHMS(date); return formatDateYMDHM(date);
}; };
const StyledTimeContainer = styled('span')(({ theme }) => ({ const StyledTimeContainer = styled('span')(({ theme }) => ({
@ -74,7 +74,7 @@ export const MilestoneNextStartTime = ({
); );
const text = projectedStartTime const text = projectedStartTime
? `Starting ${formatSmartDate(projectedStartTime)}` ? `Starting after ${formatSmartDate(projectedStartTime)}`
: 'Waiting to start'; : 'Waiting to start';
return ( 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 { interface IMilestoneTransitionDisplayProps {
intervalMinutes: number; intervalMinutes: number;
targetMilestoneId: string; targetMilestoneId: string;
sourceMilestoneStartedAt?: string | null;
onSave: ( onSave: (
payload: ChangeMilestoneProgressionSchema, payload: ChangeMilestoneProgressionSchema,
) => Promise<{ shouldReset?: boolean }>; ) => Promise<{ shouldReset?: boolean }>;
@ -105,6 +112,7 @@ interface IMilestoneTransitionDisplayProps {
export const MilestoneTransitionDisplay = ({ export const MilestoneTransitionDisplay = ({
intervalMinutes, intervalMinutes,
targetMilestoneId, targetMilestoneId,
sourceMilestoneStartedAt,
onSave, onSave,
onDelete, onDelete,
milestoneName, milestoneName,
@ -119,6 +127,8 @@ export const MilestoneTransitionDisplay = ({
timeValue: initial.value, timeValue: initial.value,
timeUnit: initial.unit, timeUnit: initial.unit,
}, },
sourceMilestoneStartedAt,
status,
); );
const currentIntervalMinutes = form.getIntervalMinutes(); const currentIntervalMinutes = form.getIntervalMinutes();
@ -130,9 +140,19 @@ export const MilestoneTransitionDisplay = ({
form.setTimeUnit(newInitial.unit); form.setTimeUnit(newInitial.unit);
}, [intervalMinutes]); }, [intervalMinutes]);
useEffect(() => {
if (!hasChanged) {
form.clearErrors();
}
}, [hasChanged, form.clearErrors]);
const handleSave = async () => { const handleSave = async () => {
if (!hasChanged) return; if (!hasChanged) return;
if (!form.validate()) {
return;
}
const payload: ChangeMilestoneProgressionSchema = { const payload: ChangeMilestoneProgressionSchema = {
targetMilestone: targetMilestoneId, targetMilestone: targetMilestoneId,
transitionCondition: { transitionCondition: {
@ -151,6 +171,7 @@ export const MilestoneTransitionDisplay = ({
const initial = getTimeValueAndUnitFromMinutes(intervalMinutes); const initial = getTimeValueAndUnitFromMinutes(intervalMinutes);
form.setTimeValue(initial.value); form.setTimeValue(initial.value);
form.setTimeUnit(initial.unit); form.setTimeUnit(initial.unit);
form.clearErrors();
}; };
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
@ -192,6 +213,9 @@ export const MilestoneTransitionDisplay = ({
</StyledButtonGroup> </StyledButtonGroup>
)} )}
</StyledDisplayContainer> </StyledDisplayContainer>
{form.errors.time && (
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
)}
{hasChanged && ( {hasChanged && (
<StyledButtonGroup hasChanged={true}> <StyledButtonGroup hasChanged={true}>
<Button <Button

View File

@ -93,6 +93,8 @@ export const MilestoneAutomation = ({
<MilestoneProgressionForm <MilestoneProgressionForm
sourceMilestoneId={milestone.id} sourceMilestoneId={milestone.id}
targetMilestoneId={nextMilestoneId} targetMilestoneId={nextMilestoneId}
sourceMilestoneStartedAt={milestone.startedAt}
status={status}
onSubmit={onChangeProgression} onSubmit={onChangeProgression}
onCancel={onCloseProgressionForm} onCancel={onCloseProgressionForm}
/> />
@ -102,6 +104,7 @@ export const MilestoneAutomation = ({
effectiveTransitionCondition.intervalMinutes effectiveTransitionCondition.intervalMinutes
} }
targetMilestoneId={nextMilestoneId} targetMilestoneId={nextMilestoneId}
sourceMilestoneStartedAt={milestone.startedAt}
onSave={onChangeProgression} onSave={onChangeProgression}
onDelete={() => onDeleteProgression(milestone)} onDelete={() => onDeleteProgression(milestone)}
milestoneName={milestone.name} 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 const MAX_INTERVAL_MINUTES = 525600; // 365 days
@ -42,6 +45,8 @@ export const useMilestoneProgressionForm = (
timeUnit: initialTimeUnit = 'hours', timeUnit: initialTimeUnit = 'hours',
timeValue: initialTimeValue = 5, timeValue: initialTimeValue = 5,
}: MilestoneProgressionFormDefaults = {}, }: MilestoneProgressionFormDefaults = {},
sourceMilestoneStartedAt?: string | null,
status?: MilestoneStatus,
) => { ) => {
const [timeUnit, setTimeUnit] = useState<TimeUnit>(initialTimeUnit); const [timeUnit, setTimeUnit] = useState<TimeUnit>(initialTimeUnit);
const [timeValue, setTimeValue] = useState(initialTimeValue); const [timeValue, setTimeValue] = useState(initialTimeValue);
@ -78,6 +83,22 @@ export const useMilestoneProgressionForm = (
newErrors.time = 'Time interval cannot exceed 365 days'; 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); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
@ -96,12 +117,17 @@ export const useMilestoneProgressionForm = (
} }
}; };
const clearErrors = useCallback(() => {
setErrors({});
}, []);
return { return {
timeUnit, timeUnit,
setTimeUnit, setTimeUnit,
timeValue, timeValue,
setTimeValue, setTimeValue,
errors, errors,
clearErrors,
validate, validate,
getProgressionPayload, getProgressionPayload,
getIntervalMinutes, getIntervalMinutes,