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:
parent
f2115cc3db
commit
8072bc6706
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user