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:
parent
2f315545a3
commit
2823c94a38
@ -70,6 +70,7 @@ const MilestoneListRendererCore = ({
|
||||
.intervalMinutes
|
||||
}
|
||||
targetMilestoneId={nextMilestoneId}
|
||||
sourceMilestoneStartedAt={milestone.startedAt}
|
||||
onSave={async (payload) => {
|
||||
await onUpdateAutomation(
|
||||
milestone.id,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user