From 596577a1b7911e7b300ddbe07ba3bb8383cc6ad8 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 3 Mar 2025 13:17:55 +0100 Subject: [PATCH] feat: use new strategy list in release plans (#9405) Here's an initial first pass of replacing the strategy lists in release plan milestones. The existing MilestoneCard has been moved to a Legacy file to avoid conflicts. This PR places the strategies in a list and changes the background color of the list items (the strategies themselves still have a white background, however). It also re-orders the buttons in the footer and places the milestone-level drag handle outside the milestone card. ![image](https://github.com/user-attachments/assets/5807bf09-ecbc-4539-a507-03482face154) ## For later Changing out the strategy list item itself hasn't been done yet. I want to see if we can re-use the existing strategy draggable item instead of making a copy. There's some dependencies on project path params etc that need to be worked out first, though, so I'd prefer to do get these initial changes through first. --- .../StrategySeparator/StrategySeparator.tsx | 2 +- .../EnvironmentAccordionBody.tsx | 4 +- .../MilestoneCard/LegacyMilestoneCard.tsx | 528 ++++++++++++++++++ .../MilestoneCard/MilestoneCard.tsx | 199 +++---- .../MilestoneList/MilestoneList.tsx | 7 +- 5 files changed, 638 insertions(+), 102 deletions(-) create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/LegacyMilestoneCard.tsx diff --git a/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx b/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx index 9cbf96d745..b73c7b840c 100644 --- a/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx +++ b/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx @@ -14,5 +14,5 @@ const Chip = styled('div')(({ theme }) => ({ })); export const StrategySeparator = () => { - return OR; + return OR; }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx index 5a46fbbc48..f712504cb9 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx @@ -36,13 +36,13 @@ const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({ }, })); -const StyledContentList = styled('ol')({ +export const StyledContentList = styled('ol')({ listStyle: 'none', padding: 0, margin: 0, }); -const StyledListItem = styled('li', { +export const StyledListItem = styled('li', { shouldForwardProp: (prop) => prop !== 'type', })<{ type?: 'release plan' | 'strategy' }>(({ theme, type }) => ({ borderBottom: `1px solid ${theme.palette.divider}`, diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/LegacyMilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/LegacyMilestoneCard.tsx new file mode 100644 index 0000000000..1b346a56f2 --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/LegacyMilestoneCard.tsx @@ -0,0 +1,528 @@ +import { + Box, + Button, + Card, + Grid, + Popover, + styled, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + FormHelperText, +} from '@mui/material'; +import Delete from '@mui/icons-material/DeleteOutlined'; +import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; +import { type DragEventHandler, type RefObject, useRef, useState } from 'react'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import { MilestoneCardName } from './MilestoneCardName'; +import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenu/MilestoneStrategyMenuCards'; +import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { ReleasePlanTemplateAddStrategyForm } from '../../MilestoneStrategy/ReleasePlanTemplateAddStrategyForm'; +import DragIndicator from '@mui/icons-material/DragIndicator'; +import { type OnMoveItem, useDragItem } from 'hooks/useDragItem'; +import type { IExtendedMilestonePayload } from 'component/releases/hooks/useTemplateForm'; + +const StyledMilestoneCard = styled(Card, { + shouldForwardProp: (prop) => prop !== 'hasError', +})<{ hasError: boolean }>(({ theme, hasError }) => ({ + marginTop: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + boxShadow: 'none', + border: `1px solid ${hasError ? theme.palette.error.border : 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, +})); + +const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({ + padding: theme.spacing(2, 2), +})); + +const StyledGridItem = styled(Grid)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', +})); + +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', + }, + backgroundColor: theme.palette.background.default, + '&:before': { + opacity: '0 !important', + }, + '&.Mui-expanded': { marginTop: `${theme.spacing(2)} !important` }, +})); + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + boxShadow: 'none', + padding: theme.spacing(1.5, 2), + borderRadius: theme.shape.borderRadiusMedium, + [theme.breakpoints.down(400)]: { + padding: theme.spacing(1, 2), + }, + '&.Mui-focusVisible': { + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(0.5, 2, 0.3, 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, +})); + +const StyledMilestoneActionGrid = styled(Grid)(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledIconButton = styled(IconButton)(({ theme }) => ({ + marginLeft: theme.spacing(1), + color: theme.palette.primary.main, +})); + +const StyledDragIcon = styled(IconButton)(({ theme }) => ({ + padding: 0, + cursor: 'grab', + transition: 'color 0.2s ease-in-out', + marginRight: theme.spacing(1), + '& > svg': { + color: 'action.active', + }, +})); + +export interface IMilestoneCardProps { + milestone: IExtendedMilestonePayload; + milestoneChanged: (milestone: IExtendedMilestonePayload) => void; + errors: { [key: string]: string }; + clearErrors: () => void; + removable: boolean; + onDeleteMilestone: () => void; + index: number; + onMoveItem: OnMoveItem; +} + +export const MilestoneCard = ({ + milestone, + milestoneChanged, + errors, + clearErrors, + removable, + onDeleteMilestone, + index, + onMoveItem, +}: IMilestoneCardProps) => { + const [anchor, setAnchor] = useState(); + const [dragItem, setDragItem] = useState<{ + id: string; + index: number; + height: number; + } | null>(null); + const [addUpdateStrategyOpen, setAddUpdateStrategyOpen] = useState(false); + const [strategyModeEdit, setStrategyModeEdit] = useState(false); + const [expanded, setExpanded] = useState(Boolean(milestone.startExpanded)); + const isPopoverOpen = Boolean(anchor); + const popoverId = isPopoverOpen + ? 'MilestoneStrategyMenuPopover' + : undefined; + + const dragHandleRef = useRef(null); + + const dragItemRef = useDragItem( + index, + onMoveItem, + dragHandleRef, + ); + + const dragHandle = ( + + + + ); + + const onClose = () => { + setAnchor(undefined); + }; + + const [currentStrategy, setCurrentStrategy] = useState< + Omit + >({ + name: 'flexibleRollout', + parameters: { rollout: '50' }, + constraints: [], + title: '', + id: 'temp', + }); + + const milestoneStrategyChanged = ( + strategy: Omit, + ) => { + const strategies = milestone.strategies || []; + milestoneChanged({ + ...milestone, + strategies: [ + ...strategies.map((strat) => + strat.id === strategy.id ? strategy : strat, + ), + ], + }); + }; + + const milestoneStrategyAdded = ( + strategy: Omit, + ) => { + milestoneChanged({ + ...milestone, + strategies: [ + ...(milestone.strategies || []), + { + ...strategy, + strategyName: strategy.strategyName, + sortOrder: milestone.strategies?.length || 0, + }, + ], + }); + }; + + const addUpdateStrategy = ( + strategy: Omit, + ) => { + const existingStrategy = milestone.strategies?.find( + (strat) => strat.id === strategy.id, + ); + if (existingStrategy) { + milestoneStrategyChanged(strategy); + } else { + milestoneStrategyAdded(strategy); + setExpanded(true); + } + setAddUpdateStrategyOpen(false); + setStrategyModeEdit(false); + setCurrentStrategy({ + name: 'flexibleRollout', + parameters: { rollout: '50' }, + constraints: [], + title: '', + id: 'temp', + }); + clearErrors(); + }; + + const openAddUpdateStrategyForm = ( + strategy: Omit, + editing: boolean, + ) => { + setStrategyModeEdit(editing); + setCurrentStrategy(strategy); + setAddUpdateStrategyOpen(true); + }; + + const onStrategyDragOver = + (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 onStrategyDragStartRef = + ( + 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 onStrategyDragEnd = () => { + 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 ( + <> + + + + + {dragHandle} + + + + + + + + + ({ + paddingBottom: theme.spacing(1), + }), + }} + > + { + openAddUpdateStrategyForm( + strategy, + false, + ); + }} + /> + + + + + + + + {errors?.[milestone.id]} + + + { + setAddUpdateStrategyOpen(false); + setStrategyModeEdit(false); + }} + open={addUpdateStrategyOpen} + > + { + setAddUpdateStrategyOpen(false); + setStrategyModeEdit(false); + }} + editMode={strategyModeEdit} + /> + + + ); + } + + return ( + <> + setExpanded(change)} + > + } + ref={dragItemRef} + > + {dragHandle} + + + + {milestone.strategies.map((strg, index) => ( +
+ + milestoneStrategyDeleted(strg.id) + } + onEditClick={() => { + openAddUpdateStrategyForm(strg, true); + }} + isDragging={dragItem?.id === strg.id} + strategy={strg} + /> +
+ ))} + + setAnchor(ev.currentTarget)} + > + Add strategy + + + ({ + paddingBottom: theme.spacing(1), + }), + }} + > + { + openAddUpdateStrategyForm(strategy, false); + }} + /> + + +
+
+ + + {errors?.[milestone.id]} + + + { + setAddUpdateStrategyOpen(false); + setStrategyModeEdit(false); + }} + open={addUpdateStrategyOpen} + > + { + setAddUpdateStrategyOpen(false); + setStrategyModeEdit(false); + }} + editMode={strategyModeEdit} + /> + + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx index 1b346a56f2..ec81a804bb 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx @@ -1,8 +1,6 @@ import { - Box, Button, Card, - Grid, Popover, styled, Accordion, @@ -23,13 +21,25 @@ import { ReleasePlanTemplateAddStrategyForm } from '../../MilestoneStrategy/Rele import DragIndicator from '@mui/icons-material/DragIndicator'; import { type OnMoveItem, useDragItem } from 'hooks/useDragItem'; import type { IExtendedMilestonePayload } from 'component/releases/hooks/useTemplateForm'; +import { + StyledContentList, + StyledListItem, +} from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody'; +import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator'; + +const leftPadding = 3; const StyledMilestoneCard = styled(Card, { shouldForwardProp: (prop) => prop !== 'hasError', })<{ hasError: boolean }>(({ theme, hasError }) => ({ marginTop: theme.spacing(2), + position: 'relative', + overflow: 'initial', display: 'flex', - flexDirection: 'column', + alignItems: 'center', + padding: theme.spacing(2, 2), + paddingLeft: theme.spacing(leftPadding), + flexDirection: 'row', justifyContent: 'space-between', boxShadow: 'none', border: `1px solid ${hasError ? theme.palette.error.border : theme.palette.divider}`, @@ -41,12 +51,9 @@ const StyledMilestoneCard = styled(Card, { backgroundColor: theme.palette.background.default, })); -const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({ - padding: theme.spacing(2, 2), -})); - -const StyledGridItem = styled(Grid)(({ theme }) => ({ +const FlexContainer = styled('div')(({ theme }) => ({ display: 'flex', + flexFlow: 'row', alignItems: 'center', })); @@ -74,6 +81,7 @@ const StyledAccordion = styled(Accordion)(({ theme }) => ({ const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ boxShadow: 'none', padding: theme.spacing(1.5, 2), + paddingLeft: theme.spacing(leftPadding), borderRadius: theme.shape.borderRadiusMedium, [theme.breakpoints.down(400)]: { padding: theme.spacing(1, 2), @@ -88,22 +96,16 @@ 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, + backgroundColor: theme.palette.background.elevation1, })); -const StyledAccordionFooter = styled(Grid)(({ theme }) => ({ +const StyledAccordionFooter = styled('div')(({ theme }) => ({ padding: theme.spacing(2), - paddingTop: 0, - backgroundColor: theme.palette.background.default, - borderRadius: theme.shape.borderRadiusMedium, -})); - -const StyledMilestoneActionGrid = styled(Grid)(({ theme }) => ({ display: 'flex', justifyContent: 'flex-end', + gap: theme.spacing(3), + backgroundColor: 'inherit', + borderRadius: theme.shape.borderRadiusMedium, })); const StyledIconButton = styled(IconButton)(({ theme }) => ({ @@ -113,9 +115,10 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ const StyledDragIcon = styled(IconButton)(({ theme }) => ({ padding: 0, + position: 'absolute', cursor: 'grab', + left: theme.spacing(-4), transition: 'color 0.2s ease-in-out', - marginRight: theme.spacing(1), '& > svg': { color: 'action.active', }, @@ -345,59 +348,51 @@ export const MilestoneCard = ({ } ref={dragItemRef} > - - - - {dragHandle} - - - - - - - + {dragHandle} - ({ - paddingBottom: theme.spacing(1), - }), - }} - > - { - openAddUpdateStrategyForm( - strategy, - false, - ); - }} - /> - - - - + + + + + + + + + + ({ + paddingBottom: theme.spacing(1), + }), + }} + > + { + openAddUpdateStrategyForm(strategy, false); + }} + /> + + @@ -433,7 +428,11 @@ export const MilestoneCard = ({ onChange={(e, change) => setExpanded(change)} > } + expandIcon={ + + } ref={dragItemRef} > {dragHandle} @@ -445,32 +444,29 @@ export const MilestoneCard = ({ /> - {milestone.strategies.map((strg, index) => ( -
- - milestoneStrategyDeleted(strg.id) - } - onEditClick={() => { - openAddUpdateStrategyForm(strg, true); - }} - isDragging={dragItem?.id === strg.id} - strategy={strg} - /> -
- ))} + + {milestone.strategies.map((strg, index) => ( + + {index > 0 ? : null} + + + milestoneStrategyDeleted(strg.id) + } + onEditClick={() => { + openAddUpdateStrategyForm(strg, true); + }} + isDragging={dragItem?.id === strg.id} + strategy={strg} + /> + + ))} + - setAnchor(ev.currentTarget)} - > - Add strategy - + setAnchor(ev.currentTarget)} + > + Add strategy + { + const useNewMilestoneCard = useUiFlag('flagOverviewRedesign'); const onMoveItem: OnMoveItem = useCallback( async (dragIndex: number, dropIndex: number) => { if (dragIndex !== dropIndex) { @@ -54,10 +57,12 @@ export const MilestoneList = ({ ); }; + const Card = useNewMilestoneCard ? MilestoneCard : LegacyMilestoneCard; + return ( <> {milestones.map((milestone, index) => ( -