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
|
.intervalMinutes
|
||||||
}
|
}
|
||||||
targetMilestoneId={nextMilestoneId}
|
targetMilestoneId={nextMilestoneId}
|
||||||
|
sourceMilestoneStartedAt={milestone.startedAt}
|
||||||
onSave={async (payload) => {
|
onSave={async (payload) => {
|
||||||
await onUpdateAutomation(
|
await onUpdateAutomation(
|
||||||
milestone.id,
|
milestone.id,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user