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

feat: implement milestone progression form (#10749)

This commit is contained in:
Fredrik Strand Oseberg 2025-10-08 10:15:08 +02:00 committed by GitHub
parent f2115cc3db
commit 8072bc6706
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 461 additions and 38 deletions

View File

@ -0,0 +1,258 @@
import { useState } from 'react';
import {
Button,
MenuItem,
Select,
styled,
TextField,
type SelectChangeEvent,
} from '@mui/material';
import BoltIcon from '@mui/icons-material/Bolt';
import {
useMilestoneProgressionForm,
type TimeUnit,
} from '../hooks/useMilestoneProgressionForm.js';
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
const StyledFormContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1.5),
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.spacing(0.75),
border: `1px solid ${theme.palette.divider}`,
boxShadow: theme.boxShadows.elevated,
position: 'relative',
marginLeft: theme.spacing(3.25),
marginTop: theme.spacing(1.5),
marginBottom: theme.spacing(1.5),
animation: 'slideDown 0.5s ease-out',
'@keyframes slideDown': {
from: {
opacity: 0,
transform: 'translateY(-24px)',
},
to: {
opacity: 1,
transform: 'translateY(0)',
},
},
}));
const StyledTopRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1.5),
}));
const StyledIcon = styled(BoltIcon)(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: 20,
flexShrink: 0,
backgroundColor: theme.palette.background.elevation1,
borderRadius: '50%',
border: `1px solid ${theme.palette.divider}`,
}));
const StyledLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.primary,
fontSize: theme.typography.body2.fontSize,
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 }) => ({
display: 'flex',
gap: theme.spacing(1),
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: theme.spacing(1.5),
marginTop: theme.spacing(1),
borderTop: `1px solid ${theme.palette.divider}`,
}));
const StyledErrorMessage = styled('span')(({ theme }) => ({
color: theme.palette.error.main,
fontSize: theme.typography.body2.fontSize,
marginRight: 'auto',
}));
interface IMilestoneProgressionFormProps {
sourceMilestoneId: string;
targetMilestoneId: string;
projectId: string;
environment: string;
onSave: () => void;
onCancel: () => void;
}
export const MilestoneProgressionForm = ({
sourceMilestoneId,
targetMilestoneId,
projectId,
environment,
onSave,
onCancel,
}: IMilestoneProgressionFormProps) => {
const form = useMilestoneProgressionForm(
sourceMilestoneId,
targetMilestoneId,
);
const { createMilestoneProgression } = useMilestoneProgressionsApi();
const { setToastData, setToastApiError } = useToast();
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 () => {
if (isSubmitting) return;
if (!form.validate()) {
return;
}
setIsSubmitting(true);
try {
await createMilestoneProgression(
projectId,
environment,
form.getProgressionPayload(),
);
setToastData({
type: 'success',
text: 'Automation configured successfully',
});
onSave();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit();
} else if (event.key === 'Escape') {
event.preventDefault();
onCancel();
}
};
return (
<StyledFormContainer onKeyDown={handleKeyDown}>
<StyledTopRow>
<StyledIcon />
<StyledLabel>Proceed to the next milestone after</StyledLabel>
<StyledInputGroup>
<StyledTextField
type='text'
inputMode='numeric'
value={form.timeValue}
onChange={handleTimeValueChange}
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>
<StyledButtonGroup>
{form.errors.time && (
<StyledErrorMessage>{form.errors.time}</StyledErrorMessage>
)}
<Button
variant='outlined'
onClick={onCancel}
size='small'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
variant='contained'
color='primary'
onClick={handleSubmit}
size='small'
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</StyledButtonGroup>
</StyledFormContainer>
);
};

View File

@ -24,6 +24,7 @@ import { StartMilestoneChangeRequestDialog } from './ChangeRequest/StartMileston
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { Truncator } from 'component/common/Truncator/Truncator'; import { Truncator } from 'component/common/Truncator/Truncator';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { MilestoneProgressionForm } from './MilestoneProgressionForm/MilestoneProgressionForm.tsx';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(2), padding: theme.spacing(2),
@ -156,6 +157,9 @@ export const ReleasePlan = ({
const { refetch: refetchChangeRequests } = const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId); usePendingChangeRequests(projectId);
const milestoneProgressionsEnabled = useUiFlag('milestoneProgression'); const milestoneProgressionsEnabled = useUiFlag('milestoneProgression');
const [progressionFormOpenIndex, setProgressionFormOpenIndex] = useState<
number | null
>(null);
const onAddRemovePlanChangesConfirm = async () => { const onAddRemovePlanChangesConfirm = async () => {
await addChange(projectId, environment, { await addChange(projectId, environment, {
@ -263,6 +267,15 @@ export const ReleasePlan = ({
}); });
}; };
const handleProgressionSave = async () => {
setProgressionFormOpenIndex(null);
await refetch();
};
const handleProgressionCancel = () => {
setProgressionFormOpenIndex(null);
};
const activeIndex = milestones.findIndex( const activeIndex = milestones.findIndex(
(milestone) => milestone.id === activeMilestoneId, (milestone) => milestone.id === activeMilestoneId,
); );
@ -296,7 +309,15 @@ export const ReleasePlan = ({
)} )}
</StyledHeader> </StyledHeader>
<StyledBody> <StyledBody>
{milestones.map((milestone, index) => ( {milestones.map((milestone, index) => {
const isNotLastMilestone = index < milestones.length - 1;
const isProgressionFormOpen =
progressionFormOpenIndex === index;
const nextMilestoneId = milestones[index + 1]?.id || '';
const handleOpenProgressionForm = () =>
setProgressionFormOpenIndex(index);
return (
<div key={milestone.id}> <div key={milestone.id}>
<ReleasePlanMilestone <ReleasePlanMilestone
readonly={readonly} readonly={readonly}
@ -313,27 +334,65 @@ export const ReleasePlan = ({
onStartMilestone={onStartMilestone} onStartMilestone={onStartMilestone}
/> />
<ConditionallyRender <ConditionallyRender
condition={index < milestones.length - 1} condition={isNotLastMilestone}
show={ show={
<ConditionallyRender <ConditionallyRender
condition={milestoneProgressionsEnabled} condition={milestoneProgressionsEnabled}
show={ show={
<ConditionallyRender
condition={
isProgressionFormOpen
}
show={
<MilestoneProgressionForm
sourceMilestoneId={
milestone.id
}
targetMilestoneId={
nextMilestoneId
}
projectId={projectId}
environment={
environment
}
onSave={
handleProgressionSave
}
onCancel={
handleProgressionCancel
}
/>
}
elseShow={
<StyledConnectionContainer> <StyledConnectionContainer>
<StyledConnection /> <StyledConnection />
<StyledAddAutomationIconButton color='primary'> <StyledAddAutomationIconButton
onClick={
handleOpenProgressionForm
}
color='primary'
>
<Add /> <Add />
</StyledAddAutomationIconButton> </StyledAddAutomationIconButton>
<StyledAddAutomationButton color='primary'> <StyledAddAutomationButton
onClick={
handleOpenProgressionForm
}
color='primary'
>
Add automation Add automation
</StyledAddAutomationButton> </StyledAddAutomationButton>
</StyledConnectionContainer> </StyledConnectionContainer>
} }
/>
}
elseShow={<StyledConnectionSimple />} elseShow={<StyledConnectionSimple />}
/> />
} }
/> />
</div> </div>
))} );
})}
</StyledBody> </StyledBody>
<ReleasePlanRemoveDialog <ReleasePlanRemoveDialog
plan={plan} plan={plan}

View File

@ -0,0 +1,73 @@
import { useState } from 'react';
const MAX_INTERVAL_MINUTES = 525600; // 365 days
export type TimeUnit = 'minutes' | 'hours' | 'days';
interface MilestoneProgressionFormDefaults {
timeValue?: number;
timeUnit?: TimeUnit;
}
export const useMilestoneProgressionForm = (
sourceMilestoneId: string,
targetMilestoneId: string,
{
timeUnit: initialTimeUnit = 'hours',
timeValue: initialTimeValue = 5,
}: MilestoneProgressionFormDefaults = {},
) => {
const [timeUnit, setTimeUnit] = useState<TimeUnit>(initialTimeUnit);
const [timeValue, setTimeValue] = useState(initialTimeValue);
const [errors, setErrors] = useState<Record<string, string>>({});
const getIntervalMinutes = () => {
switch (timeUnit) {
case 'minutes':
return timeValue;
case 'hours':
return timeValue * 60;
case 'days':
return timeValue * 1440;
}
};
const getProgressionPayload = () => {
return {
sourceMilestone: sourceMilestoneId,
targetMilestone: targetMilestoneId,
transitionCondition: {
intervalMinutes: getIntervalMinutes(),
},
};
};
const validate = () => {
const newErrors: Record<string, string> = {};
const total = getIntervalMinutes();
if (timeValue < 0) {
newErrors.time = 'Time must be non-negative';
}
if (total === 0) {
newErrors.time = 'Time cannot be zero';
} else if (total > MAX_INTERVAL_MINUTES) {
newErrors.time = 'Time interval cannot exceed 365 days';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
return {
timeUnit,
setTimeUnit,
timeValue,
setTimeValue,
errors,
validate,
getProgressionPayload,
getIntervalMinutes,
};
};

View File

@ -0,0 +1,33 @@
import useAPI from '../useApi/useApi.js';
import type { CreateMilestoneProgressionSchema } from 'openapi/models/createMilestoneProgressionSchema';
export const useMilestoneProgressionsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createMilestoneProgression = async (
projectId: string,
environment: string,
body: CreateMilestoneProgressionSchema,
): Promise<void> => {
const requestId = 'createMilestoneProgression';
const path = `api/admin/projects/${projectId}/environments/${environment}/progressions`;
const req = createRequest(
path,
{
method: 'POST',
body: JSON.stringify(body),
},
requestId,
);
await makeRequest(req.caller, req.id);
};
return {
createMilestoneProgression,
errors,
loading,
};
};