From 15950e4ea04849c9560c2e8f446a9bea927e19ff Mon Sep 17 00:00:00 2001 From: David Leek Date: Mon, 9 Dec 2024 13:39:36 +0100 Subject: [PATCH] feat: release plan template milestone UI listing strategies (#8933) --- .../ReleasePlanTemplate/MilestoneCard.tsx | 382 ++++++++++++++---- .../ReleasePlanTemplate/MilestoneList.tsx | 17 +- .../MilestoneStrategyDraggableItem.tsx | 65 +++ .../MilestoneStrategyItem.tsx | 99 +++++ .../MilestoneStrategyMenuCard.tsx | 7 + .../MilestoneStrategyMenuCards.tsx | 9 +- .../ReleasePlanTemplateAddStrategyForm.tsx | 67 +-- .../ReleasePlanTemplate/TemplateForm.tsx | 86 ++-- frontend/src/interfaces/releasePlans.ts | 7 +- 9 files changed, 587 insertions(+), 152 deletions(-) create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyDraggableItem.tsx create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyItem.tsx diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx index 37e4fbbf37..ea798726d2 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx @@ -1,12 +1,24 @@ import Input from 'component/common/Input/Input'; -import { Box, Button, Card, Grid, Popover, styled } from '@mui/material'; +import { + Box, + Button, + Card, + Grid, + Popover, + styled, + Accordion, + AccordionSummary, + AccordionDetails, +} from '@mui/material'; import Edit from '@mui/icons-material/Edit'; import type { IReleasePlanMilestonePayload, IReleasePlanMilestoneStrategy, } from 'interfaces/releasePlans'; -import { useState } from 'react'; +import { type DragEventHandler, type RefObject, useState } from 'react'; +import ExpandMore from '@mui/icons-material/ExpandMore'; import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenuCards'; +import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem'; const StyledEditIcon = styled(Edit)(({ theme }) => ({ cursor: 'pointer', @@ -24,6 +36,7 @@ const StyledMilestoneCard = styled(Card)(({ theme }) => ({ justifyContent: 'space-between', boxShadow: 'none', border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadiusMedium, [theme.breakpoints.down('sm')]: { justifyContent: 'center', }, @@ -32,7 +45,6 @@ const StyledMilestoneCard = styled(Card)(({ theme }) => ({ '&:hover': { backgroundColor: theme.palette.neutral.light, }, - borderRadius: theme.shape.borderRadiusMedium, })); const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({ @@ -53,9 +65,58 @@ const StyledMilestoneCardTitle = styled('span')(({ theme }) => ({ fontSize: theme.fontSizes.bodySize, })); +const StyledAddStrategyButton = styled(Button)(({ theme }) => ({})); + +const StyledAccordion = styled(Accordion)(({ theme }) => ({ + marginTop: theme.spacing(2), + boxShadow: 'none', + background: 'none', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadiusMedium, + [theme.breakpoints.down('sm')]: { + justifyContent: 'center', + }, + transition: 'background-color 0.2s ease-in-out', + backgroundColor: theme.palette.background.default, + '&:hover': { + backgroundColor: theme.palette.neutral.light, + }, + '&.Mui-expanded': { + marginTop: theme.spacing(3), + }, +})); + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + boxShadow: 'none', + padding: theme.spacing(1.5, 2), + [theme.breakpoints.down(400)]: { + padding: theme.spacing(1, 2), + }, +})); + +const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ + borderBottomLeftRadius: theme.shape.borderRadiusMedium, + borderBottomRightRadius: theme.shape.borderRadiusMedium, + padding: theme.spacing(0), + [theme.breakpoints.down('md')]: { + padding: theme.spacing(2, 1), + }, + backgroundColor: theme.palette.neutral.light, +})); + +const StyledAccordionFooter = styled(Grid)(({ theme }) => ({ + padding: theme.spacing(2), + paddingTop: 0, + backgroundColor: theme.palette.background.default, + borderRadius: theme.shape.borderRadiusMedium, +})); + interface IMilestoneCardProps { milestone: IReleasePlanMilestonePayload; - milestoneNameChanged: (milestoneId: string, name: string) => void; + milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void; showAddStrategyDialog: ( milestoneId: string, strategy: Omit, @@ -66,13 +127,18 @@ interface IMilestoneCardProps { export const MilestoneCard = ({ milestone, - milestoneNameChanged, + milestoneChanged, showAddStrategyDialog, errors, clearErrors, }: IMilestoneCardProps) => { const [editMode, setEditMode] = useState(false); const [anchor, setAnchor] = useState(); + const [dragItem, setDragItem] = useState<{ + id: string; + index: number; + height: number; + } | null>(null); const isPopoverOpen = Boolean(anchor); const popoverId = isPopoverOpen ? 'MilestoneStrategyMenuPopover' @@ -83,82 +149,250 @@ export const MilestoneCard = ({ }; const onSelectStrategy = ( - milestoneId: string, strategy: Omit, ) => { showAddStrategyDialog(milestone.id, strategy); }; - return ( - - - - - {editMode && ( - - milestoneNameChanged( - milestone.id, - e.target.value, - ) - } - error={Boolean(errors?.name)} - errorText={errors?.name} - onFocus={() => clearErrors()} - onBlur={() => setEditMode(false)} - autoFocus - onKeyDownCapture={(e) => { - if (e.code === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - setEditMode(false); + const onDragOver = + (targetId: string) => + ( + ref: RefObject, + targetIndex: number, + ): DragEventHandler => + (event) => { + if (dragItem === null || ref.current === null) return; + if (dragItem.index === targetIndex || targetId === dragItem.id) + return; + + const { top, bottom } = ref.current.getBoundingClientRect(); + const overTargetTop = event.clientY - top < dragItem.height; + const overTargetBottom = bottom - event.clientY < dragItem.height; + const draggingUp = dragItem.index > targetIndex; + + // prevent oscillating by only reordering if there is sufficient space + if ( + (overTargetTop && draggingUp) || + (overTargetBottom && !draggingUp) + ) { + const oldStrategies = milestone.strategies || []; + const newStrategies = [...oldStrategies]; + const movedStrategy = newStrategies.splice( + dragItem.index, + 1, + )[0]; + newStrategies.splice(targetIndex, 0, movedStrategy); + milestoneChanged({ ...milestone, strategies: newStrategies }); + setDragItem({ + ...dragItem, + index: targetIndex, + }); + } + }; + + const onDragStartRef = + ( + ref: RefObject, + index: number, + ): DragEventHandler => + (event) => { + if (!ref.current || !milestone.strategies) { + return; + } + + setDragItem({ + id: milestone.strategies[index]?.id, + index, + height: ref.current?.offsetHeight || 0, + }); + + if (ref?.current) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/html', ref.current.outerHTML); + event.dataTransfer.setDragImage(ref.current, 20, 20); + } + }; + const onDragEnd = () => { + setDragItem(null); + onReOrderStrategies(); + }; + + const onReOrderStrategies = () => { + if (!milestone.strategies) { + return; + } + const newStrategies = [...milestone.strategies]; + newStrategies.forEach((strategy, index) => { + strategy.sortOrder = index; + }); + milestoneChanged({ ...milestone, strategies: newStrategies }); + }; + + const milestoneStrategyDeleted = (strategyId: string) => { + const strategies = milestone.strategies || []; + milestoneChanged({ + ...milestone, + strategies: [ + ...strategies.filter((strat) => strat.id !== strategyId), + ], + }); + }; + + const milestoneNameChanged = (name: string) => { + milestoneChanged({ ...milestone, name }); + }; + + if (!milestone.strategies || milestone.strategies.length === 0) { + return ( + + + + + {editMode && ( + + milestoneNameChanged(e.target.value) } - }} - /> - )} - {!editMode && ( - <> - setEditMode(true)} - > - {milestone.name} - - setEditMode(true)} + error={Boolean(errors?.name)} + errorText={errors?.name} + onFocus={() => clearErrors()} + onBlur={() => setEditMode(false)} + autoFocus + onKeyDownCapture={(e) => { + if (e.code === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + setEditMode(false); + } + }} /> - - )} - - - - ({ - paddingBottom: theme.spacing(1), - }), - }} - > - - + )} + {!editMode && ( + <> + setEditMode(true)} + > + {milestone.name} + + setEditMode(true)} + /> + + )} + + + + ({ + paddingBottom: theme.spacing(1), + }), + }} + > + + + - - - + + + ); + } + + return ( + + } + > + {editMode && ( + milestoneNameChanged(e.target.value)} + error={Boolean(errors?.name)} + errorText={errors?.name} + onFocus={() => clearErrors()} + onBlur={() => setEditMode(false)} + autoFocus + onKeyDownCapture={(e) => { + if (e.code === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + setEditMode(false); + } + }} + /> + )} + {!editMode && ( + <> + setEditMode(true)} + > + {milestone.name} + + setEditMode(true)} /> + + )} + + + {milestone.strategies.map((strg, index) => ( +
+ + milestoneStrategyDeleted(strg.id) + } + onEditClick={() => { + onSelectStrategy(strg); + }} + isDragging={false} + strategy={strg} + /> +
+ ))} + + setAnchor(ev.currentTarget)} + > + Add strategy + + ({ + paddingBottom: theme.spacing(1), + }), + }} + > + + + +
+
); }; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx index ecf161a29b..9172f45bcc 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx @@ -3,8 +3,7 @@ import type { IReleasePlanMilestoneStrategy, } from 'interfaces/releasePlans'; import { MilestoneCard } from './MilestoneCard'; -import { styled } from '@mui/material'; -import { Button } from '@mui/material'; +import { styled, Button } from '@mui/material'; import Add from '@mui/icons-material/Add'; import { v4 as uuidv4 } from 'uuid'; @@ -19,6 +18,7 @@ interface IMilestoneListProps { ) => void; errors: { [key: string]: string }; clearErrors: () => void; + milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void; } const StyledAddMilestoneButton = styled(Button)(({ theme }) => ({ @@ -32,24 +32,15 @@ export const MilestoneList = ({ openAddStrategyForm, errors, clearErrors, + milestoneChanged, }: IMilestoneListProps) => { - const milestoneNameChanged = (milestoneId: string, name: string) => { - setMilestones((prev) => - prev.map((milestone) => - milestone.id === milestoneId - ? { ...milestone, name } - : milestone, - ), - ); - }; - return ( <> {milestones.map((milestone) => ( ; + index: number; + isDragging?: boolean; + onDragStartRef: ( + ref: RefObject, + index: number, + ) => DragEventHandler; + onDragOver: ( + ref: RefObject, + index: number, + ) => DragEventHandler; + onDragEnd: () => void; + onDeleteClick: () => void; + onEditClick: () => void; +} + +export const MilestoneStrategyDraggableItem = ({ + strategy, + index, + isDragging, + onDragStartRef, + onDragOver, + onDragEnd, + onDeleteClick, + onEditClick, +}: IMilestoneStrategyDraggableItemProps) => { + const ref = useRef(null); + return ( + + {index > 0 && } + + + + + + + + + } + /> + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyItem.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyItem.tsx new file mode 100644 index 0000000000..3404eca7fa --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyItem.tsx @@ -0,0 +1,99 @@ +import { Box, IconButton, styled } from '@mui/material'; +import SplitPreviewSlider from 'component/feature/StrategyTypes/SplitPreviewSlider/SplitPreviewSlider'; +import { + formatStrategyName, + getFeatureStrategyIcon, +} from 'utils/strategyNames'; +import type { IFeatureStrategy } from 'interfaces/strategy'; +import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution'; +import type { DragEventHandler, ReactNode } from 'react'; +import DragIndicator from '@mui/icons-material/DragIndicator'; + +const StyledStrategy = styled('div')(({ theme }) => ({ + background: theme.palette.background.paper, +})); + +const DragIcon = styled(IconButton)({ + padding: 0, + cursor: 'inherit', + transition: 'color 0.2s ease-in-out', +}); + +const StyledHeader = styled('div', { + shouldForwardProp: (prop) => prop !== 'draggable', +})<{ draggable: boolean }>(({ theme, draggable }) => ({ + display: 'flex', + padding: theme.spacing(2), + gap: theme.spacing(1), + alignItems: 'center', + color: theme.palette.text.primary, + '& > svg': { + fill: theme.palette.action.disabled, + }, + borderBottom: `1px solid ${theme.palette.divider}`, +})); + +const StyledStrategyExecution = styled('div')(({ theme }) => ({ + padding: theme.spacing(2), +})); + +interface IReleasePlanMilestoneStrategyProps { + strategy: IFeatureStrategy; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; + actions?: ReactNode; +} + +export const MilestoneStrategyItem = ({ + strategy, + onDragStart, + onDragEnd, + actions, +}: IReleasePlanMilestoneStrategyProps) => { + const Icon = getFeatureStrategyIcon(strategy.strategyName); + + return ( + + + + + + + {`${formatStrategyName(String(strategy.strategyName))}${strategy.title ? `: ${strategy.title}` : ''}`} + theme.spacing(6), + alignItems: 'center', + }} + > + {actions} + + + + + {strategy.variants && + strategy.variants.length > 0 && + (strategy.disabled ? ( + + + + ) : ( + + ))} + + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx index ad7961dc05..e910fbcf0a 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx @@ -66,9 +66,16 @@ export const MilestoneStrategyMenuCard = ({ { const strat = createFeatureStrategy('', strategy); + if (strat.name === 'flexibleRollout') { + strat.parameters = { + ...strat.parameters, + groupId: '{{featureName}}', + }; + } onClick({ id: uuidv4(), name: strat.name, + strategyName: strat.name, title: '', constraints: strat.constraints, parameters: strat.parameters, diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx index da48e01219..2acdb2fb49 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx @@ -9,16 +9,13 @@ const StyledTypography = styled(Typography)(({ theme }) => ({ })); interface IMilestoneStrategyMenuCardsProps { - milestoneId: string; - openAddStrategy: ( - milestoneId: string, + openEditAddStrategy: ( strategy: Omit, ) => void; } export const MilestoneStrategyMenuCards = ({ - milestoneId, - openAddStrategy, + openEditAddStrategy, }: IMilestoneStrategyMenuCardsProps) => { const { strategies } = useStrategies(); @@ -29,7 +26,7 @@ export const MilestoneStrategyMenuCards = ({ const onClick = ( strategy: Omit, ) => { - openAddStrategy(milestoneId, strategy); + openEditAddStrategy(strategy); }; return ( diff --git a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx index de6be07697..bd6c2acb92 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx @@ -24,6 +24,7 @@ import { MilestoneStrategySegment } from './MilestoneStrategySegment'; import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints'; import { MilestoneStrategyVariants } from './MilestoneStrategyVariants'; import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; const StyledCancelButton = styled(Button)(({ theme }) => ({ marginLeft: theme.spacing(3), @@ -121,7 +122,7 @@ interface IReleasePlanTemplateAddStrategyFormProps { milestoneId: string | undefined; onCancel: () => void; strategy: Omit; - onAddStrategy: ( + onAddUpdateStrategy: ( milestoneId: string, strategy: Omit, ) => void; @@ -131,27 +132,34 @@ export const ReleasePlanTemplateAddStrategyForm = ({ milestoneId, onCancel, strategy, - onAddStrategy, + onAddUpdateStrategy, }: IReleasePlanTemplateAddStrategyFormProps) => { - const [addStrategy, setAddStrategy] = useState(strategy); + const [currentStrategy, setCurrentStrategy] = useState(strategy); const [activeTab, setActiveTab] = useState(0); + const { segments: allSegments, refetchSegments } = useSegments(); const [segments, setSegments] = useState([]); - const { strategyDefinition } = useStrategy(strategy?.name); + const { strategyDefinition } = useStrategy(strategy?.strategyName); const hasValidConstraints = useConstraintsValidation(strategy?.constraints); const errors = useFormErrors(); const showVariants = Boolean( - addStrategy?.parameters && 'stickiness' in addStrategy?.parameters, + currentStrategy?.parameters && + 'stickiness' in currentStrategy?.parameters, ); const stickiness = - addStrategy?.parameters && 'stickiness' in addStrategy?.parameters - ? String(addStrategy.parameters.stickiness) + currentStrategy?.parameters && + 'stickiness' in currentStrategy?.parameters + ? String(currentStrategy.parameters.stickiness) : 'default'; useEffect(() => { - setAddStrategy((prev) => ({ + setSegments([]); + }, []); + + useEffect(() => { + setCurrentStrategy((prev) => ({ ...prev, - variants: (addStrategy.variants || []).map((variant) => ({ + variants: (currentStrategy.variants || []).map((variant) => ({ stickiness, name: variant.name, weight: variant.weight, @@ -159,9 +167,9 @@ export const ReleasePlanTemplateAddStrategyForm = ({ weightType: variant.weightType, })), })); - }, [stickiness, JSON.stringify(addStrategy.variants)]); + }, [stickiness, JSON.stringify(currentStrategy.variants)]); - if (!strategy || !addStrategy || !strategyDefinition) { + if (!strategy || !currentStrategy || !strategyDefinition) { return null; } @@ -170,7 +178,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ }; const getTargetingCount = () => { - const constraintCount = addStrategy?.constraints?.length || 0; + const constraintCount = currentStrategy?.constraints?.length || 0; const segmentCount = segments?.length || 0; return constraintCount + segmentCount; @@ -178,7 +186,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ const validateParameter = (key: string, value: string) => true; const updateParameter = (name: string, value: string) => { - setAddStrategy( + setCurrentStrategy( produce((draft) => { if (!draft) { return; @@ -194,11 +202,12 @@ export const ReleasePlanTemplateAddStrategyForm = ({ ); }; - const addStrategyToMilestone = () => { + const AddUpdateMilestoneStrategy = () => { if (!milestoneId) { return; } - onAddStrategy(milestoneId, addStrategy); + + onAddUpdateStrategy(milestoneId, currentStrategy); }; return ( @@ -208,15 +217,17 @@ export const ReleasePlanTemplateAddStrategyForm = ({ > - {formatStrategyName(addStrategy.name || '')} - {addStrategy.name === 'flexibleRollout' && ( + {formatStrategyName(currentStrategy.strategyName || '')} + {currentStrategy.strategyName === 'flexibleRollout' && ( - {addStrategy.parameters?.rollout}% + {currentStrategy.parameters?.rollout}% )} - {!BuiltInStrategies.includes(strategy.name || 'default') && ( + {!BuiltInStrategies.includes( + strategy.strategyName || 'default', + ) && ( Custom strategies are deprecated. We recommend not @@ -251,7 +262,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ Variants - {addStrategy?.variants?.length || 0} + {currentStrategy?.variants?.length || 0} } @@ -262,16 +273,16 @@ export const ReleasePlanTemplateAddStrategyForm = ({ {activeTab === 0 && ( <> updateParameter('title', title) } /> @@ -291,8 +302,8 @@ export const ReleasePlanTemplateAddStrategyForm = ({ AND be evaluated for users and applications that match the specified preconditions. @@ -301,8 +312,8 @@ export const ReleasePlanTemplateAddStrategyForm = ({ )} {activeTab === 2 && showVariants && ( )} @@ -312,7 +323,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ color='primary' type='submit' disabled={!hasValidConstraints || errors.hasFormErrors()} - onClick={addStrategyToMilestone} + onClick={AddUpdateMilestoneStrategy} > Save strategy diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx index 9ac870b9a0..40586e432d 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx @@ -58,7 +58,7 @@ export const TemplateForm: React.FC = ({ handleSubmit, children, }) => { - const [addStrategyOpen, setAddStrategyOpen] = useState(false); + const [addUpdateStrategyOpen, setAddUpdateStrategyOpen] = useState(false); const [activeMilestoneId, setActiveMilestoneId] = useState< string | undefined >(); @@ -71,37 +71,49 @@ export const TemplateForm: React.FC = ({ title: '', id: 'temp', }); - const openAddStrategyForm = ( + const openAddUpdateStrategyForm = ( milestoneId: string, strategy: Omit, ) => { setActiveMilestoneId(milestoneId); setStrategy(strategy); - setAddStrategyOpen(true); + setAddUpdateStrategyOpen(true); }; - const addStrategy = ( + const addUpdateStrategy = ( milestoneId: string, strategy: Omit, ) => { - setMilestones((prev) => - prev.map((milestone, i) => - milestone.id === milestoneId - ? { - ...milestone, - strategies: [ - ...(milestone.strategies || []), - { - ...strategy, - strategyName: strategy.name, - sortOrder: milestone.strategies?.length || 0, - }, - ], - } - : milestone, - ), + const milestone = milestones.find((m) => m.id === milestoneId); + const existingStrategy = milestone?.strategies?.find( + (strat) => strat.id === strategy.id, ); - setAddStrategyOpen(false); + if (!milestone) { + return; + } + if (existingStrategy) { + milestoneStrategyChanged(milestone, strategy); + } else { + setMilestones((prev) => + prev.map((milestone, i) => + milestone.id === milestoneId + ? { + ...milestone, + strategies: [ + ...(milestone.strategies || []), + { + ...strategy, + strategyName: strategy.strategyName, + sortOrder: + milestone.strategies?.length || 0, + }, + ], + } + : milestone, + ), + ); + } + setAddUpdateStrategyOpen(false); setActiveMilestoneId(undefined); setStrategy({ name: 'flexibleRollout', @@ -112,6 +124,29 @@ export const TemplateForm: React.FC = ({ }); }; + const milestoneChanged = (milestone: IReleasePlanMilestonePayload) => { + setMilestones((prev) => + prev.map((mstone) => + mstone.id === milestone.id ? { ...milestone } : mstone, + ), + ); + }; + + const milestoneStrategyChanged = ( + milestone: IReleasePlanMilestonePayload, + strategy: Omit, + ) => { + const strategies = milestone.strategies || []; + milestoneChanged({ + ...milestone, + strategies: [ + ...strategies.map((strat) => + strat.id === strategy.id ? strategy : strat, + ), + ], + }); + }; + return ( = ({ {children} @@ -155,14 +191,14 @@ export const TemplateForm: React.FC = ({ {}} - open={addStrategyOpen} + open={addUpdateStrategyOpen} > { - setAddStrategyOpen(false); + setAddUpdateStrategyOpen(false); }} /> diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts index ccce5fae8f..1e4c6a5946 100644 --- a/frontend/src/interfaces/releasePlans.ts +++ b/frontend/src/interfaces/releasePlans.ts @@ -50,10 +50,5 @@ export interface IReleasePlanMilestonePayload { id: string; name: string; sortOrder: number; - strategies?: IReleasePlanStrategyPayload[]; -} - -export interface IReleasePlanStrategyPayload { - id?: string; - name: string; + strategies?: Omit[]; }