From 866441a1b66b0e333a733af3094ed63364a3ac5d Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Wed, 22 Oct 2025 12:27:24 +0200 Subject: [PATCH] feat: change request progression view (#10835) --- .../ChangeRequest/ChangeRequest.tsx | 1 + .../Change/ConsolidatedProgressionChanges.tsx | 187 +++++++++++++++ .../Changes/Change/FeatureChange.tsx | 9 +- .../Changes/Change/MilestoneListRenderer.tsx | 102 +++++++++ .../Changes/Change/ProgressionChange.tsx | 123 ++++++++++ .../Changes/Change/ReleasePlanChange.tsx | 120 +++++++++- .../Changes/Change/applyProgressionChanges.ts | 53 +++++ .../changeRequest/changeRequest.types.ts | 30 ++- .../MilestoneProgressionForm.tsx | 69 +----- .../ReleasePlan/ReleasePlan.tsx | 199 +++++++--------- .../MilestoneAutomationSection.tsx | 90 +------- .../MilestoneTransitionDisplay.tsx | 76 ++----- .../ReleasePlanMilestone.tsx | 78 +------ .../MilestoneAutomation.tsx | 141 ++++++++++++ .../ReleasePlanMilestoneItem.tsx | 213 ++++++++++++++++++ .../milestoneStatusUtils.ts | 20 ++ .../usePendingProgressionChanges.ts | 33 +++ 17 files changed, 1145 insertions(+), 399 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ConsolidatedProgressionChanges.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ProgressionChange.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/applyProgressionChanges.ts create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/usePendingProgressionChanges.ts diff --git a/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx b/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx index b7cef5c9a2..cd988c5c11 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx @@ -77,6 +77,7 @@ export const ChangeRequest: VFC = ({ change={change} feature={feature} onNavigate={onNavigate} + onRefetch={onRefetch} /> ))} {feature.defaultChange ? ( diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ConsolidatedProgressionChanges.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ConsolidatedProgressionChanges.tsx new file mode 100644 index 0000000000..918a42cae6 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ConsolidatedProgressionChanges.tsx @@ -0,0 +1,187 @@ +import type { FC } from 'react'; +import { styled } from '@mui/material'; +import type { + ChangeRequestState, + IChangeRequestCreateMilestoneProgression, + IChangeRequestUpdateMilestoneProgression, + IChangeRequestDeleteMilestoneProgression, + IChangeRequestFeature, +} from 'component/changeRequest/changeRequest.types'; +import type { IReleasePlan } from 'interfaces/releasePlans'; +import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx'; +import { + Added, + ChangeItemInfo, + ChangeItemWrapper, + Deleted, +} from './Change.styles.tsx'; +import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import { MilestoneListRenderer } from './MilestoneListRenderer.tsx'; +import { applyProgressionChanges } from './applyProgressionChanges.js'; +import { EventDiff } from 'component/events/EventDiff/EventDiff'; + +const StyledTabs = styled(Tabs)(({ theme }) => ({ + display: 'flex', + flexFlow: 'column', + gap: theme.spacing(1), +})); + +type ProgressionChange = + | IChangeRequestCreateMilestoneProgression + | IChangeRequestUpdateMilestoneProgression + | IChangeRequestDeleteMilestoneProgression; + +const getFirstChangeWithSnapshot = ( + progressionChanges: ProgressionChange[], +) => { + return ( + progressionChanges.find( + (change) => + change.payload?.snapshot && + (change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression'), + ) || progressionChanges.find((change) => change.payload?.snapshot) + ); +}; + +const getMilestonesWithAutomation = ( + progressionChanges: ProgressionChange[], +): Set => { + return new Set( + progressionChanges + .filter( + (change) => + change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression', + ) + .map((change) => change.payload.sourceMilestone) + .filter((id): id is string => Boolean(id)), + ); +}; + +const getMilestonesWithDeletedAutomation = ( + progressionChanges: ProgressionChange[], +): Set => { + return new Set( + progressionChanges + .filter((change) => change.action === 'deleteMilestoneProgression') + .map((change) => change.payload.sourceMilestone) + .filter((id): id is string => Boolean(id)), + ); +}; + +const getChangeDescriptions = ( + progressionChanges: ProgressionChange[], + basePlan: IReleasePlan, +): string[] => { + return progressionChanges.map((change) => { + const sourceId = change.payload.sourceMilestone; + const sourceName = + basePlan.milestones.find((milestone) => milestone.id === sourceId) + ?.name || sourceId; + const action = + change.action === 'createMilestoneProgression' + ? 'Adding' + : change.action === 'deleteMilestoneProgression' + ? 'Deleting' + : 'Updating'; + return `${action} automation for ${sourceName}`; + }); +}; + +export const ConsolidatedProgressionChanges: FC<{ + feature: IChangeRequestFeature; + currentReleasePlan?: IReleasePlan; + changeRequestState: ChangeRequestState; + onUpdateChangeRequestSubmit?: ( + sourceMilestoneId: string, + payload: UpdateMilestoneProgressionSchema, + ) => Promise; + onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => Promise; +}> = ({ + feature, + currentReleasePlan, + changeRequestState, + onUpdateChangeRequestSubmit, + onDeleteChangeRequestSubmit, +}) => { + // Get all progression changes for this feature + const progressionChanges = feature.changes.filter( + ( + change, + ): change is + | IChangeRequestCreateMilestoneProgression + | IChangeRequestUpdateMilestoneProgression + | IChangeRequestDeleteMilestoneProgression => + change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression' || + change.action === 'deleteMilestoneProgression', + ); + + if (progressionChanges.length === 0) return null; + + const firstChangeWithSnapshot = + getFirstChangeWithSnapshot(progressionChanges); + const basePlan = + firstChangeWithSnapshot?.payload?.snapshot || currentReleasePlan; + + if (!basePlan) { + return null; + } + + const modifiedPlan = applyProgressionChanges(basePlan, progressionChanges); + const milestonesWithAutomation = + getMilestonesWithAutomation(progressionChanges); + const milestonesWithDeletedAutomation = + getMilestonesWithDeletedAutomation(progressionChanges); + const changeDescriptions = getChangeDescriptions( + progressionChanges, + basePlan, + ); + + return ( + + + + {progressionChanges.map((change, index) => { + const Component = + change.action === 'deleteMilestoneProgression' + ? Deleted + : Added; + return ( + + {changeDescriptions[index]} + + ); + })} + +
+ + View change + View diff + +
+
+ + + + + + +
+ ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index d63c05ede5..49ab7fb3cb 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -80,6 +80,7 @@ export const FeatureChange: FC<{ feature: IChangeRequestFeature; onNavigate?: () => void; isDefaultChange?: boolean; + onRefetch?: () => void; }> = ({ index, change, @@ -88,6 +89,7 @@ export const FeatureChange: FC<{ actions, onNavigate, isDefaultChange, + onRefetch, }) => { const lastIndex = feature.defaultChange ? feature.changes.length + 1 @@ -204,7 +206,10 @@ export const FeatureChange: FC<{ )} {(change.action === 'addReleasePlan' || change.action === 'deleteReleasePlan' || - change.action === 'startMilestone') && ( + change.action === 'startMilestone' || + change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression' || + change.action === 'deleteMilestoneProgression') && ( )} diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx new file mode 100644 index 0000000000..e7494a5437 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx @@ -0,0 +1,102 @@ +import { styled } from '@mui/material'; +import type { IReleasePlan } from 'interfaces/releasePlans'; +import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import type { ChangeRequestState } from 'component/changeRequest/changeRequest.types'; +import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone'; +import { MilestoneAutomationSection } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneAutomationSection.tsx'; +import { MilestoneTransitionDisplay } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx'; +import type { MilestoneStatus } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx'; +import { Badge } from 'component/common/Badge/Badge'; + +const StyledConnection = styled('div')(({ theme }) => ({ + width: 2, + height: theme.spacing(2), + backgroundColor: theme.palette.divider, + marginLeft: theme.spacing(3.25), +})); + +interface MilestoneListRendererProps { + plan: IReleasePlan; + changeRequestState: ChangeRequestState; + milestonesWithAutomation?: Set; + milestonesWithDeletedAutomation?: Set; + onUpdateAutomation?: ( + sourceMilestoneId: string, + payload: UpdateMilestoneProgressionSchema, + ) => Promise; + onDeleteAutomation?: (sourceMilestoneId: string) => void; +} + +export const MilestoneListRenderer = ({ + plan, + changeRequestState, + milestonesWithAutomation = new Set(), + milestonesWithDeletedAutomation = new Set(), + onUpdateAutomation, + onDeleteAutomation, +}: MilestoneListRendererProps) => { + // TODO: Split into read and write model at the type level to avoid having optional handlers + const readonly = + changeRequestState === 'Applied' || changeRequestState === 'Cancelled'; + const status: MilestoneStatus = 'not-started'; + + return ( + <> + {plan.milestones.map((milestone, index) => { + const isNotLastMilestone = index < plan.milestones.length - 1; + const shouldShowAutomation = + milestonesWithAutomation.has(milestone.id) || + milestonesWithDeletedAutomation.has(milestone.id); + + const showAutomation = + isNotLastMilestone && shouldShowAutomation; + + const hasPendingDelete = milestonesWithDeletedAutomation.has( + milestone.id, + ); + + const badge = hasPendingDelete ? ( + Deleted in draft + ) : undefined; + + const automationSection = + showAutomation && milestone.transitionCondition ? ( + + { + await onUpdateAutomation?.( + milestone.id, + payload, + ); + return { shouldReset: true }; + }} + onDelete={() => + onDeleteAutomation?.(milestone.id) + } + milestoneName={milestone.name} + status={status} + badge={badge} + /> + + ) : undefined; + + return ( +
+ + {isNotLastMilestone && } +
+ ); + })} + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ProgressionChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ProgressionChange.tsx new file mode 100644 index 0000000000..2e0df9f13c --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ProgressionChange.tsx @@ -0,0 +1,123 @@ +import type { FC, ReactNode } from 'react'; +import { Typography } from '@mui/material'; +import type { + ChangeRequestState, + IChangeRequestCreateMilestoneProgression, + IChangeRequestUpdateMilestoneProgression, +} from 'component/changeRequest/changeRequest.types'; +import type { IReleasePlan } from 'interfaces/releasePlans'; +import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import { EventDiff } from 'component/events/EventDiff/EventDiff'; +import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx'; +import { + Action, + Added, + ChangeItemInfo, + ChangeItemWrapper, +} from './Change.styles.tsx'; +import { styled } from '@mui/material'; +import { MilestoneListRenderer } from './MilestoneListRenderer.tsx'; +import { applyProgressionChanges } from './applyProgressionChanges.ts'; + +const StyledTabs = styled(Tabs)(({ theme }) => ({ + display: 'flex', + flexFlow: 'column', + gap: theme.spacing(1), +})); + +interface ProgressionChangeProps { + change: + | IChangeRequestCreateMilestoneProgression + | IChangeRequestUpdateMilestoneProgression; + currentReleasePlan?: IReleasePlan; + actions?: ReactNode; + changeRequestState: ChangeRequestState; + onUpdateChangeRequestSubmit?: ( + sourceMilestoneId: string, + payload: UpdateMilestoneProgressionSchema, + ) => Promise; + onDeleteChangeRequestSubmit?: (sourceMilestoneId: string) => void; +} + +export const ProgressionChange: FC = ({ + change, + currentReleasePlan, + actions, + changeRequestState, + onUpdateChangeRequestSubmit, + onDeleteChangeRequestSubmit, +}) => { + const basePlan = change.payload.snapshot || currentReleasePlan; + if (!basePlan) return null; + + const isCreate = change.action === 'createMilestoneProgression'; + const sourceId = change.payload.sourceMilestone; + + if (!sourceId) return null; + + const sourceMilestone = basePlan.milestones.find( + (milestone) => milestone.id === sourceId, + ); + const sourceMilestoneName = sourceMilestone?.name || sourceId; + + const targetMilestoneName = isCreate + ? basePlan.milestones.find( + (milestone) => milestone.id === change.payload.targetMilestone, + )?.name || change.payload.targetMilestone + : undefined; + + const modifiedPlan = applyProgressionChanges(basePlan, [change]); + + const previousMilestone = sourceMilestone; + const newMilestone = modifiedPlan.milestones.find( + (milestone) => milestone.id === sourceId, + ); + + return ( + + + + {isCreate ? ( + <> + Adding automation to release plan + + {sourceMilestoneName} → {targetMilestoneName} + + + ) : ( + <> + Updating automation in release plan + + {sourceMilestoneName} + + + )} + +
+ + View change + View diff + + {actions} +
+
+ + + + + + +
+ ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx index f868d2aa48..73868007e3 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx @@ -5,6 +5,9 @@ import type { IChangeRequestAddReleasePlan, IChangeRequestDeleteReleasePlan, IChangeRequestStartMilestone, + IChangeRequestCreateMilestoneProgression, + IChangeRequestUpdateMilestoneProgression, + IChangeRequestDeleteMilestoneProgression, } from 'component/changeRequest/changeRequest.types'; import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview'; import { useFeatureReleasePlans } from 'hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans'; @@ -21,6 +24,12 @@ import { ChangeItemWrapper, Deleted, } from './Change.styles.tsx'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; +import useToast from 'hooks/useToast'; +import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import { ProgressionChange } from './ProgressionChange.tsx'; +import { ConsolidatedProgressionChanges } from './ConsolidatedProgressionChanges.tsx'; const StyledTabs = styled(Tabs)(({ theme }) => ({ display: 'flex', @@ -235,11 +244,16 @@ export const ReleasePlanChange: FC<{ change: | IChangeRequestAddReleasePlan | IChangeRequestDeleteReleasePlan - | IChangeRequestStartMilestone; + | IChangeRequestStartMilestone + | IChangeRequestCreateMilestoneProgression + | IChangeRequestUpdateMilestoneProgression + | IChangeRequestDeleteMilestoneProgression; environmentName: string; featureName: string; projectId: string; changeRequestState: ChangeRequestState; + feature?: any; // Optional feature object for consolidated progression changes + onRefetch?: () => void; }> = ({ actions, change, @@ -247,13 +261,100 @@ export const ReleasePlanChange: FC<{ environmentName, projectId, changeRequestState, + feature, + onRefetch, }) => { - const { releasePlans } = useFeatureReleasePlans( + const { releasePlans, refetch } = useFeatureReleasePlans( projectId, featureName, environmentName, ); const currentReleasePlan = releasePlans[0]; + const { addChange } = useChangeRequestApi(); + const { refetch: refetchChangeRequests } = + usePendingChangeRequests(projectId); + const { setToastData } = useToast(); + + const handleUpdateChangeRequestSubmit = async ( + sourceMilestoneId: string, + payload: UpdateMilestoneProgressionSchema, + ) => { + await addChange(projectId, environmentName, { + feature: featureName, + action: 'updateMilestoneProgression', + payload: { + sourceMilestone: sourceMilestoneId, + ...payload, + }, + }); + await refetchChangeRequests(); + setToastData({ + type: 'success', + text: 'Added to draft', + }); + if (onRefetch) { + await onRefetch(); + } + }; + + const handleDeleteChangeRequestSubmit = async ( + sourceMilestoneId: string, + ) => { + await addChange(projectId, environmentName, { + feature: featureName, + action: 'deleteMilestoneProgression', + payload: { + sourceMilestone: sourceMilestoneId, + }, + }); + await refetchChangeRequests(); + setToastData({ + type: 'success', + text: 'Added to draft', + }); + if (onRefetch) { + await onRefetch(); + } + }; + + // If this is a progression change and we have the full feature object, + // check if we should consolidate with other progression changes + if ( + feature && + (change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression' || + change.action === 'deleteMilestoneProgression') + ) { + const progressionChanges = feature.changes.filter( + ( + change, + ): change is + | IChangeRequestCreateMilestoneProgression + | IChangeRequestUpdateMilestoneProgression + | IChangeRequestDeleteMilestoneProgression => + change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression' || + change.action === 'deleteMilestoneProgression', + ); + + // Only render if this is the first progression change + const isFirstProgression = + progressionChanges.length > 0 && progressionChanges[0] === change; + + if (!isFirstProgression) { + return null; // Skip rendering, will be handled by the first one + } + + return ( + + ); + } return ( <> @@ -282,6 +383,21 @@ export const ReleasePlanChange: FC<{ actions={actions} /> )} + {(change.action === 'createMilestoneProgression' || + change.action === 'updateMilestoneProgression') && ( + + )} ); }; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/applyProgressionChanges.ts b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/applyProgressionChanges.ts new file mode 100644 index 0000000000..855e4af708 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/applyProgressionChanges.ts @@ -0,0 +1,53 @@ +import type { IReleasePlan } from 'interfaces/releasePlans'; +import type { + IChangeRequestCreateMilestoneProgression, + IChangeRequestUpdateMilestoneProgression, + IChangeRequestDeleteMilestoneProgression, +} from 'component/changeRequest/changeRequest.types'; + +type ProgressionChange = + | IChangeRequestCreateMilestoneProgression + | IChangeRequestUpdateMilestoneProgression + | IChangeRequestDeleteMilestoneProgression; + +export const applyProgressionChanges = ( + basePlan: IReleasePlan, + progressionChanges: ProgressionChange[], +): IReleasePlan => { + return { + ...basePlan, + milestones: basePlan.milestones.map((milestone) => { + const createChange = progressionChanges.find( + (change): change is IChangeRequestCreateMilestoneProgression => + change.action === 'createMilestoneProgression' && + change.payload.sourceMilestone === milestone.id, + ); + const updateChange = progressionChanges.find( + (change): change is IChangeRequestUpdateMilestoneProgression => + change.action === 'updateMilestoneProgression' && + change.payload.sourceMilestone === milestone.id, + ); + const deleteChange = progressionChanges.find( + (change): change is IChangeRequestDeleteMilestoneProgression => + change.action === 'deleteMilestoneProgression' && + change.payload.sourceMilestone === milestone.id, + ); + + if (deleteChange) { + return { + ...milestone, + transitionCondition: null, + }; + } + + const change = updateChange || createChange; + if (change) { + return { + ...milestone, + transitionCondition: change.payload.transitionCondition, + }; + } + return milestone; + }), + }; +}; diff --git a/frontend/src/component/changeRequest/changeRequest.types.ts b/frontend/src/component/changeRequest/changeRequest.types.ts index e12f40084c..a5f96c3f91 100644 --- a/frontend/src/component/changeRequest/changeRequest.types.ts +++ b/frontend/src/component/changeRequest/changeRequest.types.ts @@ -137,7 +137,8 @@ type ChangeRequestPayload = | ChangeRequestDeleteReleasePlan | ChangeRequestStartMilestone | ChangeRequestCreateMilestoneProgression - | ChangeRequestUpdateMilestoneProgression; + | ChangeRequestUpdateMilestoneProgression + | ChangeRequestDeleteMilestoneProgression; export interface IChangeRequestAddStrategy extends IChangeRequestChangeBase { action: 'addStrategy'; @@ -206,6 +207,12 @@ export interface IChangeRequestUpdateMilestoneProgression payload: ChangeRequestUpdateMilestoneProgression; } +export interface IChangeRequestDeleteMilestoneProgression + extends IChangeRequestChangeBase { + action: 'deleteMilestoneProgression'; + payload: ChangeRequestDeleteMilestoneProgression; +} + export interface IChangeRequestReorderStrategy extends IChangeRequestChangeBase { action: 'reorderStrategy'; @@ -255,7 +262,8 @@ export type IFeatureChange = | IChangeRequestDeleteReleasePlan | IChangeRequestStartMilestone | IChangeRequestCreateMilestoneProgression - | IChangeRequestUpdateMilestoneProgression; + | IChangeRequestUpdateMilestoneProgression + | IChangeRequestDeleteMilestoneProgression; export type ISegmentChange = | IChangeRequestUpdateSegment @@ -288,13 +296,24 @@ type ChangeRequestStartMilestone = { snapshot?: IReleasePlan; }; -type ChangeRequestCreateMilestoneProgression = CreateMilestoneProgressionSchema; +type ChangeRequestCreateMilestoneProgression = + CreateMilestoneProgressionSchema & { + snapshot?: IReleasePlan; + }; type ChangeRequestUpdateMilestoneProgression = UpdateMilestoneProgressionSchema & { - sourceMilestoneId: string; + sourceMilestoneId?: string; + sourceMilestone?: string; // Backward compatibility for existing change requests + snapshot?: IReleasePlan; }; +type ChangeRequestDeleteMilestoneProgression = { + sourceMilestoneId?: string; + sourceMilestone?: string; // Backward compatibility for existing change requests + snapshot?: IReleasePlan; +}; + export type ChangeRequestAddStrategy = Pick< IFeatureStrategy, | 'parameters' @@ -334,4 +353,5 @@ export type ChangeRequestAction = | 'deleteReleasePlan' | 'startMilestone' | 'createMilestoneProgression' - | 'updateMilestoneProgression'; + | 'updateMilestoneProgression' + | 'deleteMilestoneProgression'; 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 2ae484db9b..d5db801319 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx @@ -1,12 +1,7 @@ -import { useState } from 'react'; import { Button, styled } from '@mui/material'; import BoltIcon from '@mui/icons-material/Bolt'; 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'; -import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import type { CreateMilestoneProgressionSchema } from 'openapi'; const StyledFormContainer = styled('div')(({ theme }) => ({ @@ -60,74 +55,27 @@ const StyledErrorMessage = styled('span')(({ theme }) => ({ interface IMilestoneProgressionFormProps { sourceMilestoneId: string; targetMilestoneId: string; - projectId: string; - environment: string; - featureName: string; - onSave: () => void; + onSubmit: (payload: CreateMilestoneProgressionSchema) => Promise; onCancel: () => void; - onChangeRequestSubmit?: ( - progressionPayload: CreateMilestoneProgressionSchema, - ) => void; } export const MilestoneProgressionForm = ({ sourceMilestoneId, targetMilestoneId, - projectId, - environment, - featureName, - onSave, + onSubmit, onCancel, - onChangeRequestSubmit, }: IMilestoneProgressionFormProps) => { const form = useMilestoneProgressionForm( sourceMilestoneId, targetMilestoneId, ); - const { createMilestoneProgression } = useMilestoneProgressionsApi(); - const { setToastData, setToastApiError } = useToast(); - const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); - - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleChangeRequestSubmit = () => { - const progressionPayload = form.getProgressionPayload(); - onChangeRequestSubmit?.(progressionPayload); - }; - - const handleDirectSubmit = async () => { - setIsSubmitting(true); - try { - await createMilestoneProgression( - projectId, - environment, - featureName, - form.getProgressionPayload(), - ); - setToastData({ - type: 'success', - text: 'Automation configured successfully', - }); - onSave(); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } finally { - setIsSubmitting(false); - } - }; const handleSubmit = async () => { - if (isSubmitting) return; - if (!form.validate()) { return; } - if (isChangeRequestConfigured(environment) && onChangeRequestSubmit) { - handleChangeRequestSubmit(); - } else { - await handleDirectSubmit(); - } + await onSubmit(form.getProgressionPayload()); }; const handleKeyDown = (event: React.KeyboardEvent) => { @@ -150,19 +98,13 @@ export const MilestoneProgressionForm = ({ timeUnit={form.timeUnit} onTimeValueChange={form.handleTimeValueChange} onTimeUnitChange={form.handleTimeUnitChange} - disabled={isSubmitting} /> {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 db220b3f89..5a6d73feec 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -13,8 +13,6 @@ import type { import { useState } from 'react'; import { formatUnknownError } from 'utils/formatUnknownError'; import { ReleasePlanRemoveDialog } from './ReleasePlanRemoveDialog.tsx'; -import { ReleasePlanMilestone } from './ReleasePlanMilestone/ReleasePlanMilestone.tsx'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; @@ -22,13 +20,13 @@ import { ReleasePlanChangeRequestDialog } from './ChangeRequest/ReleasePlanChang import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { Truncator } from 'component/common/Truncator/Truncator'; import { useUiFlag } from 'hooks/useUiFlag'; -import { MilestoneProgressionForm } from './MilestoneProgressionForm/MilestoneProgressionForm.tsx'; import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi'; import { DeleteProgressionDialog } from './DeleteProgressionDialog.tsx'; import type { CreateMilestoneProgressionSchema, UpdateMilestoneProgressionSchema, } from 'openapi'; +import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx'; const StyledContainer = styled('div')(({ theme }) => ({ padding: theme.spacing(2), @@ -75,17 +73,6 @@ const StyledBody = styled('div')(({ theme }) => ({ flexDirection: 'column', })); -const StyledConnection = styled('div', { - shouldForwardProp: (prop) => prop !== 'isCompleted', -})<{ isCompleted: boolean }>(({ theme, isCompleted }) => ({ - width: 2, - height: theme.spacing(2), - backgroundColor: isCompleted - ? theme.palette.divider - : theme.palette.primary.main, - marginLeft: theme.spacing(3.25), -})); - interface IReleasePlanProps { plan: IReleasePlan; environmentIsDisabled?: boolean; @@ -140,8 +127,47 @@ export const ReleasePlan = ({ >(null); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const { addChange } = useChangeRequestApi(); - const { refetch: refetchChangeRequests } = + const { data: pendingChangeRequests, refetch: refetchChangeRequests } = usePendingChangeRequests(projectId); + + // Find progression changes for this feature in pending change requests + const getPendingProgressionChange = (sourceMilestoneId: string) => { + if (!pendingChangeRequests) return null; + + for (const changeRequest of pendingChangeRequests) { + if (changeRequest.environment !== environment) continue; + + const featureInChangeRequest = changeRequest.features.find( + (featureItem) => featureItem.name === featureName, + ); + if (!featureInChangeRequest) continue; + + // Look for update or delete progression changes + const progressionChange = featureInChangeRequest.changes.find( + (change: any) => + (change.action === 'updateMilestoneProgression' && + (change.payload.sourceMilestoneId === + sourceMilestoneId || + change.payload.sourceMilestone === + sourceMilestoneId)) || + (change.action === 'deleteMilestoneProgression' && + (change.payload.sourceMilestoneId === + sourceMilestoneId || + change.payload.sourceMilestone === + sourceMilestoneId)), + ); + + if (progressionChange) { + return { + action: progressionChange.action, + payload: progressionChange.payload, + changeRequestId: changeRequest.id, + }; + } + } + + return null; + }; const milestoneProgressionsEnabled = useUiFlag('milestoneProgression'); const [progressionFormOpenIndex, setProgressionFormOpenIndex] = useState< number | null @@ -181,7 +207,6 @@ export const ReleasePlan = ({ action: 'createMilestoneProgression', payload: changeRequestAction.payload, }); - setProgressionFormOpenIndex(null); break; case 'updateMilestoneProgression': @@ -214,6 +239,7 @@ export const ReleasePlan = ({ }); setChangeRequestAction(null); + setProgressionFormOpenIndex(null); }; const confirmRemoveReleasePlan = () => { @@ -288,33 +314,19 @@ export const ReleasePlan = ({ }); }; - const handleProgressionSave = async () => { - setProgressionFormOpenIndex(null); - await refetch(); - }; - - const handleProgressionCancel = () => { - setProgressionFormOpenIndex(null); - }; - - const handleProgressionChangeRequestSubmit = ( - payload: CreateMilestoneProgressionSchema, + const handleAddToChangeRequest = ( + action: + | { + type: 'createMilestoneProgression'; + payload: CreateMilestoneProgressionSchema; + } + | { + type: 'updateMilestoneProgression'; + sourceMilestoneId: string; + payload: UpdateMilestoneProgressionSchema; + }, ) => { - setChangeRequestAction({ - type: 'createMilestoneProgression', - payload, - }); - }; - - const handleUpdateProgressionChangeRequestSubmit = ( - sourceMilestoneId: string, - payload: UpdateMilestoneProgressionSchema, - ) => { - setChangeRequestAction({ - type: 'updateMilestoneProgression', - sourceMilestoneId, - payload, - }); + setChangeRequestAction(action); }; const handleDeleteProgression = (milestone: IReleasePlanMilestone) => { @@ -392,80 +404,35 @@ export const ReleasePlan = ({ )} - {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 ( -
- - handleDeleteProgression(milestone) - : undefined - } - automationForm={ - isProgressionFormOpen ? ( - - handleProgressionChangeRequestSubmit( - payload, - ) - } - /> - ) : undefined - } - projectId={projectId} - environment={environment} - featureName={featureName} - onUpdate={refetch} - onUpdateChangeRequestSubmit={ - handleUpdateProgressionChangeRequestSubmit - } - allMilestones={milestones} - activeMilestoneId={activeMilestoneId} - /> - - } - /> -
- ); - })} + {milestones.map((milestone, index) => ( + + ))}
prop !== 'status', @@ -24,97 +21,18 @@ const StyledAutomationContainer = styled('div', { }, })); -const StyledAddAutomationButton = styled(Button)(({ theme }) => ({ - textTransform: 'none', - fontWeight: theme.typography.fontWeightBold, - fontSize: theme.typography.body2.fontSize, - padding: 0, - minWidth: 'auto', - gap: theme.spacing(1), - '&:hover': { - backgroundColor: 'transparent', - }, - '& .MuiButton-startIcon': { - margin: 0, - width: 20, - height: 20, - border: `1px solid ${theme.palette.primary.main}`, - backgroundColor: theme.palette.background.elevation2, - borderRadius: '50%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - '& svg': { - fontSize: 14, - color: theme.palette.primary.main, - }, - }, -})); - interface IMilestoneAutomationSectionProps { - showAutomation?: boolean; status?: MilestoneStatus; - onAddAutomation?: () => void; - onDeleteAutomation?: () => void; - automationForm?: React.ReactNode; - transitionCondition?: { - intervalMinutes: number; - } | null; - milestoneName: string; - projectId: string; - environment: string; - featureName: string; - sourceMilestoneId: string; - onUpdate: () => void; - onUpdateChangeRequestSubmit?: ( - sourceMilestoneId: string, - payload: UpdateMilestoneProgressionSchema, - ) => void; + children: React.ReactNode; } export const MilestoneAutomationSection = ({ - showAutomation, status, - onAddAutomation, - onDeleteAutomation, - automationForm, - transitionCondition, - milestoneName, - projectId, - environment, - featureName, - sourceMilestoneId, - onUpdate, - onUpdateChangeRequestSubmit, + children, }: IMilestoneAutomationSectionProps) => { - if (!showAutomation) return null; - return ( - {automationForm ? ( - automationForm - ) : transitionCondition ? ( - - ) : ( - } - > - Add automation - - )} + {children} ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx index 81aa6709de..b5039a40a8 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx @@ -2,17 +2,14 @@ import BoltIcon from '@mui/icons-material/Bolt'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; 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'; -import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import type { UpdateMilestoneProgressionSchema } from 'openapi'; +import type { ReactNode } from 'react'; +import { useEffect } from 'react'; const StyledDisplayContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -61,52 +58,44 @@ const StyledButtonGroup = styled('div')(({ theme }) => ({ interface IMilestoneTransitionDisplayProps { intervalMinutes: number; + onSave: ( + payload: UpdateMilestoneProgressionSchema, + ) => Promise<{ shouldReset?: boolean }>; onDelete: () => void; milestoneName: string; status?: MilestoneStatus; - projectId: string; - environment: string; - featureName: string; - sourceMilestoneId: string; - onUpdate: () => void; - onChangeRequestSubmit?: ( - sourceMilestoneId: string, - payload: UpdateMilestoneProgressionSchema, - ) => void; + badge?: ReactNode; } export const MilestoneTransitionDisplay = ({ intervalMinutes, + onSave, onDelete, milestoneName, status, - projectId, - environment, - featureName, - sourceMilestoneId, - onUpdate, - onChangeRequestSubmit, + badge, }: IMilestoneTransitionDisplayProps) => { - const { updateMilestoneProgression } = useMilestoneProgressionsApi(); - const { setToastData, setToastApiError } = useToast(); - const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); - const initial = getTimeValueAndUnitFromMinutes(intervalMinutes); const form = useMilestoneProgressionForm( - sourceMilestoneId, - sourceMilestoneId, // We don't need targetMilestone for edit, just reuse source + '', // sourceMilestoneId not needed for display + '', // targetMilestoneId not needed for display { timeValue: initial.value, timeUnit: initial.unit, }, ); - const [isSubmitting, setIsSubmitting] = useState(false); const currentIntervalMinutes = form.getIntervalMinutes(); const hasChanged = currentIntervalMinutes !== intervalMinutes; + useEffect(() => { + const newInitial = getTimeValueAndUnitFromMinutes(intervalMinutes); + form.setTimeValue(newInitial.value); + form.setTimeUnit(newInitial.unit); + }, [intervalMinutes]); + const handleSave = async () => { - if (isSubmitting || !hasChanged) return; + if (!hasChanged) return; const payload: UpdateMilestoneProgressionSchema = { transitionCondition: { @@ -114,29 +103,10 @@ export const MilestoneTransitionDisplay = ({ }, }; - if (isChangeRequestConfigured(environment) && onChangeRequestSubmit) { - onChangeRequestSubmit(sourceMilestoneId, payload); - return; - } + const result = await onSave(payload); - setIsSubmitting(true); - try { - await updateMilestoneProgression( - projectId, - environment, - featureName, - sourceMilestoneId, - payload, - ); - setToastData({ - type: 'success', - text: 'Automation updated successfully', - }); - onUpdate(); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } finally { - setIsSubmitting(false); + if (result?.shouldReset) { + handleReset(); } }; @@ -168,7 +138,6 @@ export const MilestoneTransitionDisplay = ({ timeUnit={form.timeUnit} onTimeValueChange={form.handleTimeValueChange} onTimeUnitChange={form.handleTimeUnitChange} - disabled={isSubmitting} /> @@ -178,17 +147,16 @@ export const MilestoneTransitionDisplay = ({ color='primary' onClick={handleSave} size='small' - disabled={isSubmitting} > - {isSubmitting ? 'Saving...' : 'Save'} + Save )} + {badge} 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 9e467ce69c..599aa85a2a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx @@ -17,9 +17,7 @@ import { StrategySeparator } from 'component/common/StrategySeparator/StrategySe import { StrategyItem } from '../../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx'; import { StrategyList } from 'component/common/StrategyList/StrategyList'; import { StrategyListItem } from 'component/common/StrategyList/StrategyListItem'; -import { MilestoneAutomationSection } from './MilestoneAutomationSection.tsx'; import { formatDateYMDHMS } from 'utils/formatDate'; -import type { UpdateMilestoneProgressionSchema } from 'openapi'; const StyledAccordion = styled(Accordion, { shouldForwardProp: (prop) => prop !== 'status' && prop !== 'hasAutomation', @@ -100,18 +98,7 @@ interface IReleasePlanMilestoneProps { status?: MilestoneStatus; onStartMilestone?: (milestone: IReleasePlanMilestone) => void; readonly?: boolean; - showAutomation?: boolean; - onAddAutomation?: () => void; - onDeleteAutomation?: () => void; - automationForm?: React.ReactNode; - projectId?: string; - environment?: string; - featureName?: string; - onUpdate?: () => void; - onUpdateChangeRequestSubmit?: ( - sourceMilestoneId: string, - payload: UpdateMilestoneProgressionSchema, - ) => void; + automationSection?: React.ReactNode; allMilestones: IReleasePlanMilestone[]; activeMilestoneId?: string; } @@ -121,24 +108,17 @@ export const ReleasePlanMilestone = ({ status = 'not-started', onStartMilestone, readonly, - showAutomation, - onAddAutomation, - onDeleteAutomation, - automationForm, - projectId, - environment, - featureName, - onUpdate, - onUpdateChangeRequestSubmit, + automationSection, allMilestones, activeMilestoneId, }: IReleasePlanMilestoneProps) => { const [expanded, setExpanded] = useState(false); + const hasAutomation = Boolean(automationSection); if (!milestone.strategies.length) { return ( - + @@ -181,29 +161,7 @@ export const ReleasePlanMilestone = ({ - {showAutomation && - projectId && - environment && - featureName && - onUpdate && ( - - )} + {automationSection} ); } @@ -212,7 +170,7 @@ export const ReleasePlanMilestone = ({ setExpanded(expanded)} > }> @@ -274,29 +232,7 @@ export const ReleasePlanMilestone = ({ - {showAutomation && - projectId && - environment && - featureName && - onUpdate && ( - - )} + {automationSection} ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx new file mode 100644 index 0000000000..0d46bf7bea --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx @@ -0,0 +1,141 @@ +import Add from '@mui/icons-material/Add'; +import { Button, styled } from '@mui/material'; +import { Badge } from 'component/common/Badge/Badge'; +import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; +import type { + CreateMilestoneProgressionSchema, + UpdateMilestoneProgressionSchema, +} from 'openapi'; +import { MilestoneAutomationSection } from '../ReleasePlanMilestone/MilestoneAutomationSection.tsx'; +import { MilestoneTransitionDisplay } from '../ReleasePlanMilestone/MilestoneTransitionDisplay.tsx'; +import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx'; +import { MilestoneProgressionForm } from '../MilestoneProgressionForm/MilestoneProgressionForm.tsx'; +import type { PendingProgressionChange } from './ReleasePlanMilestoneItem.tsx'; + +const StyledAddAutomationButton = styled(Button)(({ theme }) => ({ + textTransform: 'none', + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.body2.fontSize, + padding: 0, + minWidth: 'auto', + gap: theme.spacing(1), + '&:hover': { + backgroundColor: 'transparent', + }, + '& .MuiButton-startIcon': { + margin: 0, + width: 20, + height: 20, + border: `1px solid ${theme.palette.primary.main}`, + backgroundColor: theme.palette.background.elevation2, + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + '& svg': { + fontSize: 14, + color: theme.palette.primary.main, + }, + }, +})); + +const StyledAddAutomationContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), +})); + +interface MilestoneAutomationProps { + milestone: IReleasePlanMilestone; + status: MilestoneStatus; + isNotLastMilestone: boolean; + nextMilestoneId: string; + milestoneProgressionsEnabled: boolean; + readonly: boolean | undefined; + isProgressionFormOpen: boolean; + effectiveTransitionCondition: IReleasePlanMilestone['transitionCondition']; + pendingProgressionChange: PendingProgressionChange | null; + onOpenProgressionForm: () => void; + onCloseProgressionForm: () => void; + onCreateProgression: ( + payload: CreateMilestoneProgressionSchema, + ) => Promise; + onUpdateProgression: ( + payload: UpdateMilestoneProgressionSchema, + ) => Promise<{ shouldReset?: boolean }>; + onDeleteProgression: (milestone: IReleasePlanMilestone) => void; +} + +export const MilestoneAutomation = ({ + milestone, + status, + isNotLastMilestone, + nextMilestoneId, + milestoneProgressionsEnabled, + readonly, + isProgressionFormOpen, + effectiveTransitionCondition, + pendingProgressionChange, + onOpenProgressionForm, + onCloseProgressionForm, + onCreateProgression, + onUpdateProgression, + onDeleteProgression, +}: MilestoneAutomationProps) => { + const showAutomation = + milestoneProgressionsEnabled && isNotLastMilestone && !readonly; + + if (!showAutomation) { + return null; + } + + const hasPendingCreate = + pendingProgressionChange?.action === 'createMilestoneProgression'; + const hasPendingUpdate = + pendingProgressionChange?.action === 'updateMilestoneProgression'; + const hasPendingDelete = + pendingProgressionChange?.action === 'deleteMilestoneProgression'; + + const badge = hasPendingDelete ? ( + Deleted in draft + ) : hasPendingUpdate ? ( + Modified in draft + ) : undefined; + + return ( + + {isProgressionFormOpen ? ( + + ) : effectiveTransitionCondition ? ( + onDeleteProgression(milestone)} + milestoneName={milestone.name} + status={status} + badge={badge} + /> + ) : ( + + } + > + Add automation + + {hasPendingCreate && ( + Modified in draft + )} + + )} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx new file mode 100644 index 0000000000..3f3014c69c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx @@ -0,0 +1,213 @@ +import { styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; +import type { + CreateMilestoneProgressionSchema, + UpdateMilestoneProgressionSchema, +} from 'openapi'; +import { ReleasePlanMilestone } from '../ReleasePlanMilestone/ReleasePlanMilestone.tsx'; +import { useMilestoneProgressionsApi } from 'hooks/api/actions/useMilestoneProgressionsApi/useMilestoneProgressionsApi'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { calculateMilestoneStatus } from './milestoneStatusUtils.js'; +import { usePendingProgressionChanges } from './usePendingProgressionChanges.js'; +import { MilestoneAutomation } from './MilestoneAutomation.tsx'; + +const StyledConnection = styled('div', { + shouldForwardProp: (prop) => prop !== 'isCompleted', +})<{ isCompleted: boolean }>(({ theme, isCompleted }) => ({ + width: 2, + height: theme.spacing(2), + backgroundColor: isCompleted + ? theme.palette.divider + : theme.palette.primary.main, + marginLeft: theme.spacing(3.25), +})); + +export interface PendingProgressionChange { + action: string; + payload: any; + changeRequestId: number; +} + +export interface IReleasePlanMilestoneItemProps { + milestone: IReleasePlanMilestone; + index: number; + milestones: IReleasePlanMilestone[]; + activeMilestoneId?: string; + activeIndex: number; + environmentIsDisabled?: boolean; + readonly?: boolean; + milestoneProgressionsEnabled: boolean; + progressionFormOpenIndex: number | null; + onSetProgressionFormOpenIndex: (index: number | null) => void; + onStartMilestone?: (milestone: IReleasePlanMilestone) => void; + onDeleteProgression: (milestone: IReleasePlanMilestone) => void; + onAddToChangeRequest: ( + action: + | { + type: 'createMilestoneProgression'; + payload: CreateMilestoneProgressionSchema; + } + | { + type: 'updateMilestoneProgression'; + sourceMilestoneId: string; + payload: UpdateMilestoneProgressionSchema; + }, + ) => void; + getPendingProgressionChange: ( + sourceMilestoneId: string, + ) => PendingProgressionChange | null; + projectId: string; + environment: string; + featureName: string; + onUpdate: () => void | Promise; +} + +export const ReleasePlanMilestoneItem = ({ + milestone, + index, + milestones, + activeMilestoneId, + activeIndex, + environmentIsDisabled, + readonly, + milestoneProgressionsEnabled, + progressionFormOpenIndex, + onSetProgressionFormOpenIndex, + onStartMilestone, + onDeleteProgression, + onAddToChangeRequest, + getPendingProgressionChange, + projectId, + environment, + featureName, + onUpdate, +}: IReleasePlanMilestoneItemProps) => { + const { createMilestoneProgression, updateMilestoneProgression } = + useMilestoneProgressionsApi(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { setToastData, setToastApiError } = useToast(); + + const isNotLastMilestone = index < milestones.length - 1; + const isProgressionFormOpen = progressionFormOpenIndex === index; + const nextMilestoneId = milestones[index + 1]?.id || ''; + const handleOpenProgressionForm = () => + onSetProgressionFormOpenIndex(index); + const handleCloseProgressionForm = () => + onSetProgressionFormOpenIndex(null); + + const handleCreateProgression = async ( + payload: CreateMilestoneProgressionSchema, + ) => { + if (isChangeRequestConfigured(environment)) { + onAddToChangeRequest({ + type: 'createMilestoneProgression', + payload, + }); + handleCloseProgressionForm(); + return; + } + + try { + await createMilestoneProgression( + projectId, + environment, + featureName, + payload, + ); + setToastData({ + type: 'success', + text: 'Automation configured successfully', + }); + handleCloseProgressionForm(); + await onUpdate(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const handleUpdateProgression = async ( + payload: UpdateMilestoneProgressionSchema, + ): Promise<{ shouldReset?: boolean }> => { + if (isChangeRequestConfigured(environment)) { + onAddToChangeRequest({ + type: 'updateMilestoneProgression', + sourceMilestoneId: milestone.id, + payload, + }); + return { shouldReset: true }; + } + + try { + await updateMilestoneProgression( + projectId, + environment, + featureName, + milestone.id, + payload, + ); + setToastData({ + type: 'success', + text: 'Automation updated successfully', + }); + await onUpdate(); + return {}; + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + return {}; + } + }; + + const status = calculateMilestoneStatus( + milestone, + activeMilestoneId, + index, + activeIndex, + environmentIsDisabled, + ); + + const { pendingProgressionChange, effectiveTransitionCondition } = + usePendingProgressionChanges(milestone, getPendingProgressionChange); + + const shouldShowAutomation = + isNotLastMilestone && milestoneProgressionsEnabled; + + const automationSection = shouldShowAutomation ? ( + + ) : undefined; + + return ( +
+ + } + /> +
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts new file mode 100644 index 0000000000..b3b19b5d72 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts @@ -0,0 +1,20 @@ +import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; +import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx'; + +export const calculateMilestoneStatus = ( + milestone: IReleasePlanMilestone, + activeMilestoneId: string | undefined, + index: number, + activeIndex: number, + environmentIsDisabled: boolean | undefined, +): MilestoneStatus => { + if (milestone.id === activeMilestoneId) { + return environmentIsDisabled ? 'paused' : 'active'; + } + + if (index < activeIndex) { + return 'completed'; + } + + return 'not-started'; +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/usePendingProgressionChanges.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/usePendingProgressionChanges.ts new file mode 100644 index 0000000000..bee14d1873 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/usePendingProgressionChanges.ts @@ -0,0 +1,33 @@ +import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; +import type { + IReleasePlanMilestoneItemProps, + PendingProgressionChange, +} from './ReleasePlanMilestoneItem.jsx'; + +interface PendingProgressionChangeResult { + pendingProgressionChange: PendingProgressionChange | null; + effectiveTransitionCondition: IReleasePlanMilestone['transitionCondition']; +} + +export const usePendingProgressionChanges = ( + milestone: IReleasePlanMilestone, + getPendingProgressionChange: IReleasePlanMilestoneItemProps['getPendingProgressionChange'], +): PendingProgressionChangeResult => { + const pendingProgressionChange = getPendingProgressionChange(milestone.id); + + // Determine effective transition condition (use pending create if exists) + let effectiveTransitionCondition = milestone.transitionCondition; + if ( + pendingProgressionChange?.action === 'createMilestoneProgression' && + 'transitionCondition' in pendingProgressionChange.payload && + pendingProgressionChange.payload.transitionCondition + ) { + effectiveTransitionCondition = + pendingProgressionChange.payload.transitionCondition; + } + + return { + pendingProgressionChange, + effectiveTransitionCondition, + }; +};