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 {
Button,
MenuItem,
Select,
styled,
TextField,
type SelectChangeEvent,
} from '@mui/material';
import { Button, styled } from '@mui/material';
import BoltIcon from '@mui/icons-material/Bolt';
import {
useMilestoneProgressionForm,
type TimeUnit,
} from '../hooks/useMilestoneProgressionForm.js';
import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionForm.js';
import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx';
const StyledFormContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -48,43 +39,6 @@ const StyledLabel = styled('span')(({ theme }) => ({
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),
@ -127,22 +81,6 @@ export const MilestoneProgressionForm = ({
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;
@ -184,37 +122,13 @@ export const MilestoneProgressionForm = ({
<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>
<MilestoneProgressionTimeInput
timeValue={form.timeValue}
timeUnit={form.timeUnit}
onTimeValueChange={form.handleTimeValueChange}
onTimeUnitChange={form.handleTimeUnitChange}
disabled={isSubmitting}
/>
</StyledTopRow>
<StyledButtonGroup>
{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
}
projectId={projectId}
environment={environment}
onUpdate={refetch}
/>
<ConditionallyRender
condition={isNotLastMilestone}

View File

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

View File

@ -1,8 +1,16 @@
import BoltIcon from '@mui/icons-material/Bolt';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { IconButton, styled } from '@mui/material';
import { formatDuration, intervalToDuration } from 'date-fns';
import { Button, IconButton, styled } from '@mui/material';
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 }) => ({
display: 'flex',
@ -32,7 +40,7 @@ const StyledIcon = styled(BoltIcon, {
padding: theme.spacing(0.25),
}));
const StyledText = styled('span', {
const StyledLabel = styled('span', {
shouldForwardProp: (prop) => prop !== 'status',
})<{ status?: MilestoneStatus }>(({ theme, status }) => ({
color:
@ -40,6 +48,13 @@ const StyledText = styled('span', {
? theme.palette.text.secondary
: theme.palette.text.primary,
fontSize: theme.typography.body2.fontSize,
flexShrink: 0,
}));
const StyledButtonGroup = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
}));
interface IMilestoneTransitionDisplayProps {
@ -47,45 +62,119 @@ interface IMilestoneTransitionDisplayProps {
onDelete: () => void;
milestoneName: string;
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 = ({
intervalMinutes,
onDelete,
milestoneName,
status,
projectId,
environment,
sourceMilestoneId,
onUpdate,
}: 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 (
<StyledDisplayContainer>
<StyledDisplayContainer onKeyDown={handleKeyDown}>
<StyledContentGroup>
<StyledIcon status={status} />
<StyledText status={status}>
Proceed to the next milestone after{' '}
{formatInterval(intervalMinutes)}
</StyledText>
<StyledLabel status={status}>
Proceed to the next milestone after
</StyledLabel>
<MilestoneProgressionTimeInput
timeValue={form.timeValue}
timeUnit={form.timeUnit}
onTimeValueChange={form.handleTimeValueChange}
onTimeUnitChange={form.handleTimeUnitChange}
disabled={isSubmitting}
/>
</StyledContentGroup>
<IconButton
onClick={onDelete}
size='small'
aria-label={`Delete automation for ${milestoneName}`}
sx={{ padding: 0.5 }}
>
<DeleteOutlineIcon fontSize='small' />
</IconButton>
<StyledButtonGroup>
{hasChanged && (
<Button
variant='contained'
color='primary'
onClick={handleSave}
size='small'
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>
);
};

View File

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

View File

@ -9,6 +9,32 @@ interface MilestoneProgressionFormDefaults {
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 = (
sourceMilestoneId: string,
targetMilestoneId: string,
@ -22,14 +48,10 @@ export const useMilestoneProgressionForm = (
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;
}
return getMinutesFromTimeValueAndUnit({
value: timeValue,
unit: timeUnit,
});
};
const getProgressionPayload = () => {
@ -60,6 +82,20 @@ export const useMilestoneProgressionForm = (
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 {
timeUnit,
setTimeUnit,
@ -69,5 +105,7 @@ export const useMilestoneProgressionForm = (
validate,
getProgressionPayload,
getIntervalMinutes,
handleTimeUnitChange,
handleTimeValueChange,
};
};

View File

@ -1,5 +1,6 @@
import useAPI from '../useApi/useApi.js';
import type { CreateMilestoneProgressionSchema } from 'openapi/models/createMilestoneProgressionSchema';
import type { UpdateMilestoneProgressionSchema } from 'openapi/models/updateMilestoneProgressionSchema';
export const useMilestoneProgressionsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
@ -25,6 +26,26 @@ export const useMilestoneProgressionsApi = () => {
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 (
projectId: string,
environment: string,
@ -45,6 +66,7 @@ export const useMilestoneProgressionsApi = () => {
return {
createMilestoneProgression,
updateMilestoneProgression,
deleteMilestoneProgression,
errors,
loading,

View File

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