diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx new file mode 100644 index 0000000000..b2621a75cc --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx @@ -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) => { + 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; + + 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 ( + + + + 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 && ( + {form.errors.time} + )} + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index c21c780d14..18436282dc 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -24,6 +24,7 @@ import { StartMilestoneChangeRequestDialog } from './ChangeRequest/StartMileston import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { Truncator } from 'component/common/Truncator/Truncator'; import { useUiFlag } from 'hooks/useUiFlag'; +import { MilestoneProgressionForm } from './MilestoneProgressionForm/MilestoneProgressionForm.tsx'; const StyledContainer = styled('div')(({ theme }) => ({ padding: theme.spacing(2), @@ -156,6 +157,9 @@ export const ReleasePlan = ({ const { refetch: refetchChangeRequests } = usePendingChangeRequests(projectId); const milestoneProgressionsEnabled = useUiFlag('milestoneProgression'); + const [progressionFormOpenIndex, setProgressionFormOpenIndex] = useState< + number | null + >(null); const onAddRemovePlanChangesConfirm = async () => { 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( (milestone) => milestone.id === activeMilestoneId, ); @@ -296,44 +309,90 @@ export const ReleasePlan = ({ )} - {milestones.map((milestone, index) => ( -
- - - - - - - - Add automation - - - } - elseShow={} - /> - } - /> -
- ))} + {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 ( +
+ + + } + elseShow={ + + + + + + + Add automation + + + } + /> + } + elseShow={} + /> + } + /> +
+ ); + })}
{ + const [timeUnit, setTimeUnit] = useState(initialTimeUnit); + const [timeValue, setTimeValue] = useState(initialTimeValue); + const [errors, setErrors] = useState>({}); + + 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 = {}; + 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, + }; +}; diff --git a/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts b/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts new file mode 100644 index 0000000000..fcfe361715 --- /dev/null +++ b/frontend/src/hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi.ts @@ -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 => { + 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, + }; +};