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

feat: add inline editing for milestone progressions (#10777)

This commit is contained in:
Fredrik Strand Oseberg 2025-10-13 11:53:45 +02:00 committed by GitHub
parent 9db7bcffd5
commit 0edbc7d595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 402 additions and 168 deletions

View File

@ -1,20 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { Button, styled } from '@mui/material';
Button,
MenuItem,
Select,
styled,
TextField,
type SelectChangeEvent,
} from '@mui/material';
import BoltIcon from '@mui/icons-material/Bolt'; import BoltIcon from '@mui/icons-material/Bolt';
import { import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js';
useMilestoneProgressionForm,
type TimeUnit,
} from '../hooks/useMilestoneProgressionForm.js';
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi'; import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
const StyledFormContainer = styled('div')(({ theme }) => ({ const StyledFormContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -48,43 +39,6 @@ const StyledLabel = styled('span')(({ theme }) => ({
flexShrink: 0, flexShrink: 0,
})); }));
const StyledInputGroup = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const StyledTextField = styled(TextField)(({ theme }) => ({
width: '60px',
'& .MuiOutlinedInput-root': {
borderRadius: theme.spacing(0.5),
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.main,
},
},
'& input': {
textAlign: 'center',
padding: theme.spacing(0.75, 1),
fontSize: theme.typography.body2.fontSize,
fontWeight: theme.typography.fontWeightMedium,
},
}));
const StyledSelect = styled(Select)(({ theme }) => ({
width: '100px',
fontSize: theme.typography.body2.fontSize,
borderRadius: theme.spacing(0.5),
'& .MuiOutlinedInput-notchedOutline': {
borderRadius: theme.spacing(0.5),
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.main,
},
'& .MuiSelect-select': {
padding: theme.spacing(0.75, 1.25),
},
}));
const StyledButtonGroup = styled('div')(({ theme }) => ({ const StyledButtonGroup = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
gap: theme.spacing(1), gap: theme.spacing(1),
@ -127,22 +81,6 @@ export const MilestoneProgressionForm = ({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const handleTimeUnitChange = (event: SelectChangeEvent<unknown>) => {
const newUnit = event.target.value as TimeUnit;
form.setTimeUnit(newUnit);
};
const handleTimeValueChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const inputValue = event.target.value;
// Only allow digits
if (inputValue === '' || /^\d+$/.test(inputValue)) {
const value = inputValue === '' ? 0 : Number.parseInt(inputValue);
form.setTimeValue(value);
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) return; if (isSubmitting) return;
@ -184,37 +122,13 @@ export const MilestoneProgressionForm = ({
<StyledTopRow> <StyledTopRow>
<StyledIcon /> <StyledIcon />
<StyledLabel>Proceed to the next milestone after</StyledLabel> <StyledLabel>Proceed to the next milestone after</StyledLabel>
<StyledInputGroup> <MilestoneProgressionTimeInput
<StyledTextField timeValue={form.timeValue}
type='text' timeUnit={form.timeUnit}
inputMode='numeric' onTimeValueChange={form.handleTimeValueChange}
value={form.timeValue} onTimeUnitChange={form.handleTimeUnitChange}
onChange={handleTimeValueChange} disabled={isSubmitting}
onPaste={(e) => { />
const pastedText = e.clipboardData.getData('text');
if (!/^\d+$/.test(pastedText)) {
e.preventDefault();
}
}}
inputProps={{
pattern: '[0-9]*',
'aria-label': 'Time duration value',
'aria-describedby': 'time-unit-select',
}}
size='small'
/>
<StyledSelect
value={form.timeUnit}
onChange={handleTimeUnitChange}
size='small'
aria-label='Time unit'
id='time-unit-select'
>
<MenuItem value='minutes'>Minutes</MenuItem>
<MenuItem value='hours'>Hours</MenuItem>
<MenuItem value='days'>Days</MenuItem>
</StyledSelect>
</StyledInputGroup>
</StyledTopRow> </StyledTopRow>
<StyledButtonGroup> <StyledButtonGroup>
{form.errors.time && ( {form.errors.time && (

View File

@ -0,0 +1,99 @@
import {
MenuItem,
Select,
styled,
TextField,
type SelectChangeEvent,
} from '@mui/material';
import type { TimeUnit } from '../hooks/useMilestoneProgressionForm.js';
const StyledInputGroup = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const StyledTextField = styled(TextField)(({ theme }) => ({
width: '60px',
'& .MuiOutlinedInput-root': {
borderRadius: theme.spacing(0.5),
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.main,
},
},
'& input': {
textAlign: 'center',
padding: theme.spacing(0.75, 1),
fontSize: theme.typography.body2.fontSize,
fontWeight: theme.typography.fontWeightMedium,
},
}));
const StyledSelect = styled(Select)(({ theme }) => ({
width: '100px',
fontSize: theme.typography.body2.fontSize,
borderRadius: theme.spacing(0.5),
'& .MuiOutlinedInput-notchedOutline': {
borderRadius: theme.spacing(0.5),
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.main,
},
'& .MuiSelect-select': {
padding: theme.spacing(0.75, 1.25),
},
}));
interface IMilestoneProgressionTimeInputProps {
timeValue: number;
timeUnit: TimeUnit;
onTimeValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onTimeUnitChange: (event: SelectChangeEvent<unknown>) => void;
disabled?: boolean;
}
const handleNumericPaste = (e: React.ClipboardEvent) => {
const pastedText = e.clipboardData.getData('text');
if (!/^\d+$/.test(pastedText)) {
e.preventDefault();
}
};
export const MilestoneProgressionTimeInput = ({
timeValue,
timeUnit,
onTimeValueChange,
onTimeUnitChange,
disabled,
}: IMilestoneProgressionTimeInputProps) => {
return (
<StyledInputGroup>
<StyledTextField
type='text'
inputMode='numeric'
value={timeValue}
onChange={onTimeValueChange}
onPaste={handleNumericPaste}
inputProps={{
pattern: '[0-9]*',
'aria-label': 'Time duration value',
'aria-describedby': 'time-unit-select',
}}
size='small'
disabled={disabled}
/>
<StyledSelect
value={timeUnit}
onChange={onTimeUnitChange}
size='small'
aria-label='Time unit'
id='time-unit-select'
disabled={disabled}
>
<MenuItem value='minutes'>Minutes</MenuItem>
<MenuItem value='hours'>Hours</MenuItem>
<MenuItem value='days'>Days</MenuItem>
</StyledSelect>
</StyledInputGroup>
);
};

View File

@ -368,6 +368,9 @@ export const ReleasePlan = ({
/> />
) : undefined ) : undefined
} }
projectId={projectId}
environment={environment}
onUpdate={refetch}
/> />
<ConditionallyRender <ConditionallyRender
condition={isNotLastMilestone} condition={isNotLastMilestone}

View File

@ -60,6 +60,10 @@ interface IMilestoneAutomationSectionProps {
intervalMinutes: number; intervalMinutes: number;
} | null; } | null;
milestoneName: string; milestoneName: string;
projectId: string;
environment: string;
sourceMilestoneId: string;
onUpdate: () => void;
} }
export const MilestoneAutomationSection = ({ export const MilestoneAutomationSection = ({
@ -70,6 +74,10 @@ export const MilestoneAutomationSection = ({
automationForm, automationForm,
transitionCondition, transitionCondition,
milestoneName, milestoneName,
projectId,
environment,
sourceMilestoneId,
onUpdate,
}: IMilestoneAutomationSectionProps) => { }: IMilestoneAutomationSectionProps) => {
if (!showAutomation) return null; if (!showAutomation) return null;
@ -83,6 +91,10 @@ export const MilestoneAutomationSection = ({
onDelete={onDeleteAutomation!} onDelete={onDeleteAutomation!}
milestoneName={milestoneName} milestoneName={milestoneName}
status={status} status={status}
projectId={projectId}
environment={environment}
sourceMilestoneId={sourceMilestoneId}
onUpdate={onUpdate}
/> />
) : ( ) : (
<StyledAddAutomationButton <StyledAddAutomationButton

View File

@ -1,8 +1,16 @@
import BoltIcon from '@mui/icons-material/Bolt'; import BoltIcon from '@mui/icons-material/Bolt';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { IconButton, styled } from '@mui/material'; import { Button, IconButton, styled } from '@mui/material';
import { formatDuration, intervalToDuration } from 'date-fns';
import type { MilestoneStatus } from './ReleasePlanMilestoneStatus.tsx'; import type { MilestoneStatus } from './ReleasePlanMilestoneStatus.tsx';
import { useState } from 'react';
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { MilestoneProgressionTimeInput } from '../MilestoneProgressionForm/MilestoneProgressionTimeInput.tsx';
import {
useMilestoneProgressionForm,
getTimeValueAndUnitFromMinutes,
} from '../hooks/useMilestoneProgressionForm.js';
const StyledDisplayContainer = styled('div')(({ theme }) => ({ const StyledDisplayContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -32,7 +40,7 @@ const StyledIcon = styled(BoltIcon, {
padding: theme.spacing(0.25), padding: theme.spacing(0.25),
})); }));
const StyledText = styled('span', { const StyledLabel = styled('span', {
shouldForwardProp: (prop) => prop !== 'status', shouldForwardProp: (prop) => prop !== 'status',
})<{ status?: MilestoneStatus }>(({ theme, status }) => ({ })<{ status?: MilestoneStatus }>(({ theme, status }) => ({
color: color:
@ -40,6 +48,13 @@ const StyledText = styled('span', {
? theme.palette.text.secondary ? theme.palette.text.secondary
: theme.palette.text.primary, : theme.palette.text.primary,
fontSize: theme.typography.body2.fontSize, fontSize: theme.typography.body2.fontSize,
flexShrink: 0,
}));
const StyledButtonGroup = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
})); }));
interface IMilestoneTransitionDisplayProps { interface IMilestoneTransitionDisplayProps {
@ -47,45 +62,119 @@ interface IMilestoneTransitionDisplayProps {
onDelete: () => void; onDelete: () => void;
milestoneName: string; milestoneName: string;
status?: MilestoneStatus; status?: MilestoneStatus;
projectId: string;
environment: string;
sourceMilestoneId: string;
onUpdate: () => void;
} }
const formatInterval = (minutes: number): string => {
if (minutes === 0) return '0 minutes';
const duration = intervalToDuration({
start: 0,
end: minutes * 60 * 1000,
});
return formatDuration(duration, {
format: ['days', 'hours', 'minutes'],
delimiter: ', ',
});
};
export const MilestoneTransitionDisplay = ({ export const MilestoneTransitionDisplay = ({
intervalMinutes, intervalMinutes,
onDelete, onDelete,
milestoneName, milestoneName,
status, status,
projectId,
environment,
sourceMilestoneId,
onUpdate,
}: IMilestoneTransitionDisplayProps) => { }: IMilestoneTransitionDisplayProps) => {
const { updateMilestoneProgression } = useMilestoneProgressionsApi();
const { setToastData, setToastApiError } = useToast();
const initial = getTimeValueAndUnitFromMinutes(intervalMinutes);
const form = useMilestoneProgressionForm(
sourceMilestoneId,
sourceMilestoneId, // We don't need targetMilestone for edit, just reuse source
{
timeValue: initial.value,
timeUnit: initial.unit,
},
);
const [isSubmitting, setIsSubmitting] = useState(false);
const currentIntervalMinutes = form.getIntervalMinutes();
const hasChanged = currentIntervalMinutes !== intervalMinutes;
const handleSave = async () => {
if (isSubmitting || !hasChanged) return;
setIsSubmitting(true);
try {
await updateMilestoneProgression(
projectId,
environment,
sourceMilestoneId,
{
transitionCondition: {
intervalMinutes: currentIntervalMinutes,
},
},
);
setToastData({
type: 'success',
text: 'Automation updated successfully',
});
onUpdate();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setIsSubmitting(false);
}
};
const handleReset = () => {
const initial = getTimeValueAndUnitFromMinutes(intervalMinutes);
form.setTimeValue(initial.value);
form.setTimeUnit(initial.unit);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && hasChanged) {
event.preventDefault();
handleSave();
} else if (event.key === 'Escape' && hasChanged) {
event.preventDefault();
handleReset();
}
};
return ( return (
<StyledDisplayContainer> <StyledDisplayContainer onKeyDown={handleKeyDown}>
<StyledContentGroup> <StyledContentGroup>
<StyledIcon status={status} /> <StyledIcon status={status} />
<StyledText status={status}> <StyledLabel status={status}>
Proceed to the next milestone after{' '} Proceed to the next milestone after
{formatInterval(intervalMinutes)} </StyledLabel>
</StyledText> <MilestoneProgressionTimeInput
timeValue={form.timeValue}
timeUnit={form.timeUnit}
onTimeValueChange={form.handleTimeValueChange}
onTimeUnitChange={form.handleTimeUnitChange}
disabled={isSubmitting}
/>
</StyledContentGroup> </StyledContentGroup>
<IconButton <StyledButtonGroup>
onClick={onDelete} {hasChanged && (
size='small' <Button
aria-label={`Delete automation for ${milestoneName}`} variant='contained'
sx={{ padding: 0.5 }} color='primary'
> onClick={handleSave}
<DeleteOutlineIcon fontSize='small' /> size='small'
</IconButton> disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
)}
<IconButton
onClick={onDelete}
size='small'
aria-label={`Delete automation for ${milestoneName}`}
sx={{ padding: 0.5 }}
disabled={isSubmitting}
>
<DeleteOutlineIcon fontSize='small' />
</IconButton>
</StyledButtonGroup>
</StyledDisplayContainer> </StyledDisplayContainer>
); );
}; };

View File

@ -17,6 +17,7 @@ import { StrategyItem } from '../../FeatureOverviewEnvironments/FeatureOverviewE
import { StrategyList } from 'component/common/StrategyList/StrategyList'; import { StrategyList } from 'component/common/StrategyList/StrategyList';
import { StrategyListItem } from 'component/common/StrategyList/StrategyListItem'; import { StrategyListItem } from 'component/common/StrategyList/StrategyListItem';
import { MilestoneAutomationSection } from './MilestoneAutomationSection.tsx'; import { MilestoneAutomationSection } from './MilestoneAutomationSection.tsx';
import { formatDateYMDHMS } from 'utils/formatDate';
const StyledAccordion = styled(Accordion, { const StyledAccordion = styled(Accordion, {
shouldForwardProp: (prop) => prop !== 'status' && prop !== 'hasAutomation', shouldForwardProp: (prop) => prop !== 'status' && prop !== 'hasAutomation',
@ -72,6 +73,18 @@ const StyledSecondaryLabel = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
})); }));
const StyledStartedAt = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
fontWeight: theme.typography.fontWeightRegular,
}));
const StyledStatusRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
padding: 0, padding: 0,
})); }));
@ -89,6 +102,9 @@ interface IReleasePlanMilestoneProps {
onAddAutomation?: () => void; onAddAutomation?: () => void;
onDeleteAutomation?: () => void; onDeleteAutomation?: () => void;
automationForm?: React.ReactNode; automationForm?: React.ReactNode;
projectId?: string;
environment?: string;
onUpdate?: () => void;
} }
export const ReleasePlanMilestone = ({ export const ReleasePlanMilestone = ({
@ -100,6 +116,9 @@ export const ReleasePlanMilestone = ({
onAddAutomation, onAddAutomation,
onDeleteAutomation, onDeleteAutomation,
automationForm, automationForm,
projectId,
environment,
onUpdate,
}: IReleasePlanMilestoneProps) => { }: IReleasePlanMilestoneProps) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -112,29 +131,49 @@ export const ReleasePlanMilestone = ({
<StyledTitle status={status}> <StyledTitle status={status}>
{milestone.name} {milestone.name}
</StyledTitle> </StyledTitle>
{!readonly && onStartMilestone && ( {(!readonly && onStartMilestone) ||
<ReleasePlanMilestoneStatus (status === 'active' && milestone.startedAt) ? (
status={status} <StyledStatusRow>
onStartMilestone={() => {!readonly && onStartMilestone && (
onStartMilestone(milestone) <ReleasePlanMilestoneStatus
} status={status}
/> onStartMilestone={() =>
)} onStartMilestone(milestone)
}
/>
)}
{status === 'active' &&
milestone.startedAt && (
<StyledStartedAt>
Started{' '}
{formatDateYMDHMS(
milestone.startedAt,
)}
</StyledStartedAt>
)}
</StyledStatusRow>
) : null}
</StyledTitleContainer> </StyledTitleContainer>
<StyledSecondaryLabel> <StyledSecondaryLabel>
No strategies No strategies
</StyledSecondaryLabel> </StyledSecondaryLabel>
</StyledAccordionSummary> </StyledAccordionSummary>
</StyledAccordion> </StyledAccordion>
<MilestoneAutomationSection {showAutomation && projectId && environment && onUpdate && (
showAutomation={showAutomation} <MilestoneAutomationSection
status={status} showAutomation={showAutomation}
onAddAutomation={onAddAutomation} status={status}
onDeleteAutomation={onDeleteAutomation} onAddAutomation={onAddAutomation}
automationForm={automationForm} onDeleteAutomation={onDeleteAutomation}
transitionCondition={milestone.transitionCondition} automationForm={automationForm}
milestoneName={milestone.name} transitionCondition={milestone.transitionCondition}
/> milestoneName={milestone.name}
projectId={projectId}
environment={environment}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate}
/>
)}
</StyledMilestoneContainer> </StyledMilestoneContainer>
); );
} }
@ -151,14 +190,25 @@ export const ReleasePlanMilestone = ({
<StyledTitle status={status}> <StyledTitle status={status}>
{milestone.name} {milestone.name}
</StyledTitle> </StyledTitle>
{!readonly && onStartMilestone && ( {(!readonly && onStartMilestone) ||
<ReleasePlanMilestoneStatus (status === 'active' && milestone.startedAt) ? (
status={status} <StyledStatusRow>
onStartMilestone={() => {!readonly && onStartMilestone && (
onStartMilestone(milestone) <ReleasePlanMilestoneStatus
} status={status}
/> onStartMilestone={() =>
)} onStartMilestone(milestone)
}
/>
)}
{status === 'active' && milestone.startedAt && (
<StyledStartedAt>
Started{' '}
{formatDateYMDHMS(milestone.startedAt)}
</StyledStartedAt>
)}
</StyledStatusRow>
) : null}
</StyledTitleContainer> </StyledTitleContainer>
<StyledSecondaryLabel> <StyledSecondaryLabel>
{milestone.strategies.length === 1 {milestone.strategies.length === 1
@ -187,15 +237,21 @@ export const ReleasePlanMilestone = ({
</StrategyList> </StrategyList>
</StyledAccordionDetails> </StyledAccordionDetails>
</StyledAccordion> </StyledAccordion>
<MilestoneAutomationSection {showAutomation && projectId && environment && onUpdate && (
showAutomation={showAutomation} <MilestoneAutomationSection
status={status} showAutomation={showAutomation}
onAddAutomation={onAddAutomation} status={status}
onDeleteAutomation={onDeleteAutomation} onAddAutomation={onAddAutomation}
automationForm={automationForm} onDeleteAutomation={onDeleteAutomation}
transitionCondition={milestone.transitionCondition} automationForm={automationForm}
milestoneName={milestone.name} transitionCondition={milestone.transitionCondition}
/> milestoneName={milestone.name}
projectId={projectId}
environment={environment}
sourceMilestoneId={milestone.id}
onUpdate={onUpdate}
/>
)}
</StyledMilestoneContainer> </StyledMilestoneContainer>
); );
}; };

View File

@ -9,6 +9,32 @@ interface MilestoneProgressionFormDefaults {
timeUnit?: TimeUnit; timeUnit?: TimeUnit;
} }
export const getTimeValueAndUnitFromMinutes = (
minutes: number,
): { value: number; unit: TimeUnit } => {
if (minutes % 1440 === 0) {
return { value: minutes / 1440, unit: 'days' };
}
if (minutes % 60 === 0) {
return { value: minutes / 60, unit: 'hours' };
}
return { value: minutes, unit: 'minutes' };
};
export const getMinutesFromTimeValueAndUnit = (time: {
value: number;
unit: TimeUnit;
}): number => {
switch (time.unit) {
case 'minutes':
return time.value;
case 'hours':
return time.value * 60;
case 'days':
return time.value * 1440;
}
};
export const useMilestoneProgressionForm = ( export const useMilestoneProgressionForm = (
sourceMilestoneId: string, sourceMilestoneId: string,
targetMilestoneId: string, targetMilestoneId: string,
@ -22,14 +48,10 @@ export const useMilestoneProgressionForm = (
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const getIntervalMinutes = () => { const getIntervalMinutes = () => {
switch (timeUnit) { return getMinutesFromTimeValueAndUnit({
case 'minutes': value: timeValue,
return timeValue; unit: timeUnit,
case 'hours': });
return timeValue * 60;
case 'days':
return timeValue * 1440;
}
}; };
const getProgressionPayload = () => { const getProgressionPayload = () => {
@ -60,6 +82,20 @@ export const useMilestoneProgressionForm = (
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const handleTimeUnitChange = (event: { target: { value: unknown } }) => {
setTimeUnit(event.target.value as TimeUnit);
};
const handleTimeValueChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const inputValue = event.target.value;
if (inputValue === '' || /^\d+$/.test(inputValue)) {
const value = inputValue === '' ? 0 : Number.parseInt(inputValue);
setTimeValue(value);
}
};
return { return {
timeUnit, timeUnit,
setTimeUnit, setTimeUnit,
@ -69,5 +105,7 @@ export const useMilestoneProgressionForm = (
validate, validate,
getProgressionPayload, getProgressionPayload,
getIntervalMinutes, getIntervalMinutes,
handleTimeUnitChange,
handleTimeValueChange,
}; };
}; };

View File

@ -1,5 +1,6 @@
import useAPI from '../useApi/useApi.js'; import useAPI from '../useApi/useApi.js';
import type { CreateMilestoneProgressionSchema } from 'openapi/models/createMilestoneProgressionSchema'; import type { CreateMilestoneProgressionSchema } from 'openapi/models/createMilestoneProgressionSchema';
import type { UpdateMilestoneProgressionSchema } from 'openapi/models/updateMilestoneProgressionSchema';
export const useMilestoneProgressionsApi = () => { export const useMilestoneProgressionsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({ const { makeRequest, createRequest, errors, loading } = useAPI({
@ -25,6 +26,26 @@ export const useMilestoneProgressionsApi = () => {
await makeRequest(req.caller, req.id); await makeRequest(req.caller, req.id);
}; };
const updateMilestoneProgression = async (
projectId: string,
environment: string,
sourceMilestoneId: string,
body: UpdateMilestoneProgressionSchema,
): Promise<void> => {
const requestId = 'updateMilestoneProgression';
const path = `api/admin/projects/${projectId}/environments/${environment}/progressions/${sourceMilestoneId}`;
const req = createRequest(
path,
{
method: 'PUT',
body: JSON.stringify(body),
},
requestId,
);
await makeRequest(req.caller, req.id);
};
const deleteMilestoneProgression = async ( const deleteMilestoneProgression = async (
projectId: string, projectId: string,
environment: string, environment: string,
@ -45,6 +66,7 @@ export const useMilestoneProgressionsApi = () => {
return { return {
createMilestoneProgression, createMilestoneProgression,
updateMilestoneProgression,
deleteMilestoneProgression, deleteMilestoneProgression,
errors, errors,
loading, loading,

View File

@ -35,6 +35,7 @@ export interface IReleasePlanMilestone {
name: string; name: string;
releasePlanDefinitionId: string; releasePlanDefinitionId: string;
strategies: IReleasePlanMilestoneStrategy[]; strategies: IReleasePlanMilestoneStrategy[];
startedAt?: string | null;
transitionCondition?: { transitionCondition?: {
intervalMinutes: number; intervalMinutes: number;
} | null; } | null;