From 0edbc7d595aadb5538e6008de8936219b34c1f36 Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Mon, 13 Oct 2025 11:53:45 +0200 Subject: [PATCH] feat: add inline editing for milestone progressions (#10777) --- .../MilestoneProgressionForm.tsx | 106 ++----------- .../MilestoneProgressionTimeInput.tsx | 99 ++++++++++++ .../ReleasePlan/ReleasePlan.tsx | 3 + .../MilestoneAutomationSection.tsx | 12 ++ .../MilestoneTransitionDisplay.tsx | 149 ++++++++++++++---- .../ReleasePlanMilestone.tsx | 124 +++++++++++---- .../hooks/useMilestoneProgressionForm.ts | 54 ++++++- .../useMilestoneProgressionsApi.ts | 22 +++ frontend/src/interfaces/releasePlans.ts | 1 + 9 files changed, 402 insertions(+), 168 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionTimeInput.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx index ec9db97ae1..9f67d07479 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx @@ -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) => { - const newUnit = event.target.value as TimeUnit; - form.setTimeUnit(newUnit); - }; - - const handleTimeValueChange = ( - event: React.ChangeEvent, - ) => { - 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 = ({ Proceed to the next milestone after - - { - 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' - /> - - Minutes - Hours - Days - - + {form.errors.time && ( diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionTimeInput.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionTimeInput.tsx new file mode 100644 index 0000000000..8e0d37bda2 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionTimeInput.tsx @@ -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) => void; + onTimeUnitChange: (event: SelectChangeEvent) => 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 ( + + + + Minutes + Hours + Days + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index 6dd681f61c..b68bb03565 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -368,6 +368,9 @@ export const ReleasePlan = ({ /> ) : undefined } + projectId={projectId} + environment={environment} + onUpdate={refetch} /> 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} /> ) : ( ({ 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 ( - + - - Proceed to the next milestone after{' '} - {formatInterval(intervalMinutes)} - + + Proceed to the next milestone after + + - - - + + {hasChanged && ( + + )} + + + + ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx index 25d72f74d8..9e3f1e8557 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx @@ -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 = ({ {milestone.name} - {!readonly && onStartMilestone && ( - - onStartMilestone(milestone) - } - /> - )} + {(!readonly && onStartMilestone) || + (status === 'active' && milestone.startedAt) ? ( + + {!readonly && onStartMilestone && ( + + onStartMilestone(milestone) + } + /> + )} + {status === 'active' && + milestone.startedAt && ( + + Started{' '} + {formatDateYMDHMS( + milestone.startedAt, + )} + + )} + + ) : null} No strategies - + {showAutomation && projectId && environment && onUpdate && ( + + )} ); } @@ -151,14 +190,25 @@ export const ReleasePlanMilestone = ({ {milestone.name} - {!readonly && onStartMilestone && ( - - onStartMilestone(milestone) - } - /> - )} + {(!readonly && onStartMilestone) || + (status === 'active' && milestone.startedAt) ? ( + + {!readonly && onStartMilestone && ( + + onStartMilestone(milestone) + } + /> + )} + {status === 'active' && milestone.startedAt && ( + + Started{' '} + {formatDateYMDHMS(milestone.startedAt)} + + )} + + ) : null} {milestone.strategies.length === 1 @@ -187,15 +237,21 @@ export const ReleasePlanMilestone = ({ - + {showAutomation && projectId && environment && onUpdate && ( + + )} ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts index ee849a9f5b..654003b9bb 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionForm.ts @@ -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>({}); 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, + ) => { + 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, }; }; diff --git a/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts b/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts index b7abdcc059..0b345f762a 100644 --- a/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts +++ b/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts @@ -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 => { + 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, diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts index 6fa060e140..a839b70f5e 100644 --- a/frontend/src/interfaces/releasePlans.ts +++ b/frontend/src/interfaces/releasePlans.ts @@ -35,6 +35,7 @@ export interface IReleasePlanMilestone { name: string; releasePlanDefinitionId: string; strategies: IReleasePlanMilestoneStrategy[]; + startedAt?: string | null; transitionCondition?: { intervalMinutes: number; } | null;