1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-18 00:19:49 +01:00

feat: release plan template milestone UI listing strategies ()

This commit is contained in:
David Leek 2024-12-09 13:39:36 +01:00 committed by GitHub
parent e8179d4dd2
commit 15950e4ea0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 587 additions and 152 deletions

View File

@ -1,12 +1,24 @@
import Input from 'component/common/Input/Input'; 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 Edit from '@mui/icons-material/Edit';
import type { import type {
IReleasePlanMilestonePayload, IReleasePlanMilestonePayload,
IReleasePlanMilestoneStrategy, IReleasePlanMilestoneStrategy,
} from 'interfaces/releasePlans'; } 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 { MilestoneStrategyMenuCards } from './MilestoneStrategyMenuCards';
import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem';
const StyledEditIcon = styled(Edit)(({ theme }) => ({ const StyledEditIcon = styled(Edit)(({ theme }) => ({
cursor: 'pointer', cursor: 'pointer',
@ -24,6 +36,7 @@ const StyledMilestoneCard = styled(Card)(({ theme }) => ({
justifyContent: 'space-between', justifyContent: 'space-between',
boxShadow: 'none', boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
justifyContent: 'center', justifyContent: 'center',
}, },
@ -32,7 +45,6 @@ const StyledMilestoneCard = styled(Card)(({ theme }) => ({
'&:hover': { '&:hover': {
backgroundColor: theme.palette.neutral.light, backgroundColor: theme.palette.neutral.light,
}, },
borderRadius: theme.shape.borderRadiusMedium,
})); }));
const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({ const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({
@ -53,9 +65,58 @@ const StyledMilestoneCardTitle = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize, 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 { interface IMilestoneCardProps {
milestone: IReleasePlanMilestonePayload; milestone: IReleasePlanMilestonePayload;
milestoneNameChanged: (milestoneId: string, name: string) => void; milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void;
showAddStrategyDialog: ( showAddStrategyDialog: (
milestoneId: string, milestoneId: string,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
@ -66,13 +127,18 @@ interface IMilestoneCardProps {
export const MilestoneCard = ({ export const MilestoneCard = ({
milestone, milestone,
milestoneNameChanged, milestoneChanged,
showAddStrategyDialog, showAddStrategyDialog,
errors, errors,
clearErrors, clearErrors,
}: IMilestoneCardProps) => { }: IMilestoneCardProps) => {
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [anchor, setAnchor] = useState<Element>(); const [anchor, setAnchor] = useState<Element>();
const [dragItem, setDragItem] = useState<{
id: string;
index: number;
height: number;
} | null>(null);
const isPopoverOpen = Boolean(anchor); const isPopoverOpen = Boolean(anchor);
const popoverId = isPopoverOpen const popoverId = isPopoverOpen
? 'MilestoneStrategyMenuPopover' ? 'MilestoneStrategyMenuPopover'
@ -83,82 +149,250 @@ export const MilestoneCard = ({
}; };
const onSelectStrategy = ( const onSelectStrategy = (
milestoneId: string,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => { ) => {
showAddStrategyDialog(milestone.id, strategy); showAddStrategyDialog(milestone.id, strategy);
}; };
return ( const onDragOver =
<StyledMilestoneCard> (targetId: string) =>
<StyledMilestoneCardBody> (
<Grid container> ref: RefObject<HTMLDivElement>,
<StyledGridItem item xs={10} md={10}> targetIndex: number,
{editMode && ( ): DragEventHandler<HTMLDivElement> =>
<StyledInput (event) => {
label='' if (dragItem === null || ref.current === null) return;
value={milestone.name} if (dragItem.index === targetIndex || targetId === dragItem.id)
onChange={(e) => return;
milestoneNameChanged(
milestone.id, const { top, bottom } = ref.current.getBoundingClientRect();
e.target.value, const overTargetTop = event.clientY - top < dragItem.height;
) const overTargetBottom = bottom - event.clientY < dragItem.height;
} const draggingUp = dragItem.index > targetIndex;
error={Boolean(errors?.name)}
errorText={errors?.name} // prevent oscillating by only reordering if there is sufficient space
onFocus={() => clearErrors()} if (
onBlur={() => setEditMode(false)} (overTargetTop && draggingUp) ||
autoFocus (overTargetBottom && !draggingUp)
onKeyDownCapture={(e) => { ) {
if (e.code === 'Enter') { const oldStrategies = milestone.strategies || [];
e.preventDefault(); const newStrategies = [...oldStrategies];
e.stopPropagation(); const movedStrategy = newStrategies.splice(
setEditMode(false); dragItem.index,
1,
)[0];
newStrategies.splice(targetIndex, 0, movedStrategy);
milestoneChanged({ ...milestone, strategies: newStrategies });
setDragItem({
...dragItem,
index: targetIndex,
});
}
};
const onDragStartRef =
(
ref: RefObject<HTMLDivElement>,
index: number,
): DragEventHandler<HTMLButtonElement> =>
(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 (
<StyledMilestoneCard>
<StyledMilestoneCardBody>
<Grid container>
<StyledGridItem item xs={10} md={10}>
{editMode && (
<StyledInput
label=''
value={milestone.name}
onChange={(e) =>
milestoneNameChanged(e.target.value)
} }
}} error={Boolean(errors?.name)}
/> errorText={errors?.name}
)} onFocus={() => clearErrors()}
{!editMode && ( onBlur={() => setEditMode(false)}
<> autoFocus
<StyledMilestoneCardTitle onKeyDownCapture={(e) => {
onClick={() => setEditMode(true)} if (e.code === 'Enter') {
> e.preventDefault();
{milestone.name} e.stopPropagation();
</StyledMilestoneCardTitle> setEditMode(false);
<StyledEditIcon }
onClick={() => setEditMode(true)} }}
/> />
</> )}
)} {!editMode && (
</StyledGridItem> <>
<Grid item xs={2} md={2}> <StyledMilestoneCardTitle
<Button onClick={() => setEditMode(true)}
variant='outlined' >
color='primary' {milestone.name}
onClick={(ev) => setAnchor(ev.currentTarget)} </StyledMilestoneCardTitle>
> <StyledEditIcon
Add strategy onClick={() => setEditMode(true)}
</Button> />
<Popover </>
id={popoverId} )}
open={isPopoverOpen} </StyledGridItem>
anchorEl={anchor} <Grid item xs={2} md={2}>
onClose={onClose} <Button
onClick={onClose} variant='outlined'
PaperProps={{ color='primary'
sx: (theme) => ({ onClick={(ev) => setAnchor(ev.currentTarget)}
paddingBottom: theme.spacing(1), >
}), Add strategy
}} </Button>
> <Popover
<MilestoneStrategyMenuCards id={popoverId}
milestoneId={milestone.id} open={isPopoverOpen}
openAddStrategy={onSelectStrategy} anchorEl={anchor}
/> onClose={onClose}
</Popover> onClick={onClose}
PaperProps={{
sx: (theme) => ({
paddingBottom: theme.spacing(1),
}),
}}
>
<MilestoneStrategyMenuCards
openEditAddStrategy={onSelectStrategy}
/>
</Popover>
</Grid>
</Grid> </Grid>
</Grid> </StyledMilestoneCardBody>
</StyledMilestoneCardBody> </StyledMilestoneCard>
</StyledMilestoneCard> );
}
return (
<StyledAccordion>
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
>
{editMode && (
<StyledInput
label=''
value={milestone.name}
onChange={(e) => 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 && (
<>
<StyledMilestoneCardTitle
onClick={() => setEditMode(true)}
>
{milestone.name}
</StyledMilestoneCardTitle>
<StyledEditIcon onClick={() => setEditMode(true)} />
</>
)}
</StyledAccordionSummary>
<StyledAccordionDetails>
{milestone.strategies.map((strg, index) => (
<div key={strg.id}>
<MilestoneStrategyDraggableItem
index={index}
onDragEnd={onDragEnd}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(strg.id)}
onDeleteClick={() =>
milestoneStrategyDeleted(strg.id)
}
onEditClick={() => {
onSelectStrategy(strg);
}}
isDragging={false}
strategy={strg}
/>
</div>
))}
<StyledAccordionFooter>
<StyledAddStrategyButton
variant='outlined'
color='primary'
onClick={(ev) => setAnchor(ev.currentTarget)}
>
Add strategy
</StyledAddStrategyButton>
<Popover
id={popoverId}
open={isPopoverOpen}
anchorEl={anchor}
onClose={onClose}
onClick={onClose}
PaperProps={{
sx: (theme) => ({
paddingBottom: theme.spacing(1),
}),
}}
>
<MilestoneStrategyMenuCards
openEditAddStrategy={onSelectStrategy}
/>
</Popover>
</StyledAccordionFooter>
</StyledAccordionDetails>
</StyledAccordion>
); );
}; };

View File

@ -3,8 +3,7 @@ import type {
IReleasePlanMilestoneStrategy, IReleasePlanMilestoneStrategy,
} from 'interfaces/releasePlans'; } from 'interfaces/releasePlans';
import { MilestoneCard } from './MilestoneCard'; import { MilestoneCard } from './MilestoneCard';
import { styled } from '@mui/material'; import { styled, Button } from '@mui/material';
import { Button } from '@mui/material';
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -19,6 +18,7 @@ interface IMilestoneListProps {
) => void; ) => void;
errors: { [key: string]: string }; errors: { [key: string]: string };
clearErrors: () => void; clearErrors: () => void;
milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void;
} }
const StyledAddMilestoneButton = styled(Button)(({ theme }) => ({ const StyledAddMilestoneButton = styled(Button)(({ theme }) => ({
@ -32,24 +32,15 @@ export const MilestoneList = ({
openAddStrategyForm, openAddStrategyForm,
errors, errors,
clearErrors, clearErrors,
milestoneChanged,
}: IMilestoneListProps) => { }: IMilestoneListProps) => {
const milestoneNameChanged = (milestoneId: string, name: string) => {
setMilestones((prev) =>
prev.map((milestone) =>
milestone.id === milestoneId
? { ...milestone, name }
: milestone,
),
);
};
return ( return (
<> <>
{milestones.map((milestone) => ( {milestones.map((milestone) => (
<MilestoneCard <MilestoneCard
key={milestone.id} key={milestone.id}
milestone={milestone} milestone={milestone}
milestoneNameChanged={milestoneNameChanged} milestoneChanged={milestoneChanged}
showAddStrategyDialog={openAddStrategyForm} showAddStrategyDialog={openAddStrategyForm}
errors={errors} errors={errors}
clearErrors={clearErrors} clearErrors={clearErrors}

View File

@ -0,0 +1,65 @@
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
import { type DragEventHandler, type RefObject, useRef } from 'react';
import { Box, IconButton } from '@mui/material';
import Edit from '@mui/icons-material/Edit';
import Delete from '@mui/icons-material/DeleteOutlined';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { MilestoneStrategyItem } from './MilestoneStrategyItem';
interface IMilestoneStrategyDraggableItemProps {
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>;
index: number;
isDragging?: boolean;
onDragStartRef: (
ref: RefObject<HTMLDivElement>,
index: number,
) => DragEventHandler<HTMLButtonElement>;
onDragOver: (
ref: RefObject<HTMLDivElement>,
index: number,
) => DragEventHandler<HTMLDivElement>;
onDragEnd: () => void;
onDeleteClick: () => void;
onEditClick: () => void;
}
export const MilestoneStrategyDraggableItem = ({
strategy,
index,
isDragging,
onDragStartRef,
onDragOver,
onDragEnd,
onDeleteClick,
onEditClick,
}: IMilestoneStrategyDraggableItemProps) => {
const ref = useRef<HTMLDivElement>(null);
return (
<Box
key={strategy.id}
ref={ref}
onDragOver={onDragOver(ref, index)}
sx={{ opacity: isDragging ? '0.5' : '1' }}
>
{index > 0 && <StrategySeparator text='OR' />}
<MilestoneStrategyItem
strategy={strategy}
onDragStart={onDragStartRef(ref, index)}
onDragEnd={onDragEnd}
actions={
<>
<IconButton title='Edit strategy' onClick={onEditClick}>
<Edit />
</IconButton>
<IconButton
title='Remove release plan'
onClick={onDeleteClick}
>
<Delete />
</IconButton>
</>
}
/>
</Box>
);
};

View File

@ -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<HTMLButtonElement>;
onDragEnd?: DragEventHandler<HTMLButtonElement>;
actions?: ReactNode;
}
export const MilestoneStrategyItem = ({
strategy,
onDragStart,
onDragEnd,
actions,
}: IReleasePlanMilestoneStrategyProps) => {
const Icon = getFeatureStrategyIcon(strategy.strategyName);
return (
<StyledStrategy>
<StyledHeader draggable={Boolean(onDragStart)}>
<DragIcon
draggable
disableRipple
size='small'
onDragStart={onDragStart}
onDragEnd={onDragEnd}
sx={{ cursor: 'move' }}
>
<DragIndicator
titleAccess='Drag to reorder'
cursor='grab'
sx={{ color: 'action.active' }}
/>
</DragIcon>
<Icon />
{`${formatStrategyName(String(strategy.strategyName))}${strategy.title ? `: ${strategy.title}` : ''}`}
<Box
sx={{
marginLeft: 'auto',
display: 'flex',
minHeight: (theme) => theme.spacing(6),
alignItems: 'center',
}}
>
{actions}
</Box>
</StyledHeader>
<StyledStrategyExecution>
<StrategyExecution strategy={strategy} />
{strategy.variants &&
strategy.variants.length > 0 &&
(strategy.disabled ? (
<Box sx={{ opacity: '0.5' }}>
<SplitPreviewSlider variants={strategy.variants} />
</Box>
) : (
<SplitPreviewSlider variants={strategy.variants} />
))}
</StyledStrategyExecution>
</StyledStrategy>
);
};

View File

@ -66,9 +66,16 @@ export const MilestoneStrategyMenuCard = ({
<StyledCard <StyledCard
onClick={() => { onClick={() => {
const strat = createFeatureStrategy('', strategy); const strat = createFeatureStrategy('', strategy);
if (strat.name === 'flexibleRollout') {
strat.parameters = {
...strat.parameters,
groupId: '{{featureName}}',
};
}
onClick({ onClick({
id: uuidv4(), id: uuidv4(),
name: strat.name, name: strat.name,
strategyName: strat.name,
title: '', title: '',
constraints: strat.constraints, constraints: strat.constraints,
parameters: strat.parameters, parameters: strat.parameters,

View File

@ -9,16 +9,13 @@ const StyledTypography = styled(Typography)(({ theme }) => ({
})); }));
interface IMilestoneStrategyMenuCardsProps { interface IMilestoneStrategyMenuCardsProps {
milestoneId: string; openEditAddStrategy: (
openAddStrategy: (
milestoneId: string,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void; ) => void;
} }
export const MilestoneStrategyMenuCards = ({ export const MilestoneStrategyMenuCards = ({
milestoneId, openEditAddStrategy,
openAddStrategy,
}: IMilestoneStrategyMenuCardsProps) => { }: IMilestoneStrategyMenuCardsProps) => {
const { strategies } = useStrategies(); const { strategies } = useStrategies();
@ -29,7 +26,7 @@ export const MilestoneStrategyMenuCards = ({
const onClick = ( const onClick = (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => { ) => {
openAddStrategy(milestoneId, strategy); openEditAddStrategy(strategy);
}; };
return ( return (

View File

@ -24,6 +24,7 @@ import { MilestoneStrategySegment } from './MilestoneStrategySegment';
import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints'; import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints';
import { MilestoneStrategyVariants } from './MilestoneStrategyVariants'; import { MilestoneStrategyVariants } from './MilestoneStrategyVariants';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation'; import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
const StyledCancelButton = styled(Button)(({ theme }) => ({ const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3), marginLeft: theme.spacing(3),
@ -121,7 +122,7 @@ interface IReleasePlanTemplateAddStrategyFormProps {
milestoneId: string | undefined; milestoneId: string | undefined;
onCancel: () => void; onCancel: () => void;
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>; strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>;
onAddStrategy: ( onAddUpdateStrategy: (
milestoneId: string, milestoneId: string,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => void; ) => void;
@ -131,27 +132,34 @@ export const ReleasePlanTemplateAddStrategyForm = ({
milestoneId, milestoneId,
onCancel, onCancel,
strategy, strategy,
onAddStrategy, onAddUpdateStrategy,
}: IReleasePlanTemplateAddStrategyFormProps) => { }: IReleasePlanTemplateAddStrategyFormProps) => {
const [addStrategy, setAddStrategy] = useState(strategy); const [currentStrategy, setCurrentStrategy] = useState(strategy);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const { segments: allSegments, refetchSegments } = useSegments();
const [segments, setSegments] = useState<ISegment[]>([]); const [segments, setSegments] = useState<ISegment[]>([]);
const { strategyDefinition } = useStrategy(strategy?.name); const { strategyDefinition } = useStrategy(strategy?.strategyName);
const hasValidConstraints = useConstraintsValidation(strategy?.constraints); const hasValidConstraints = useConstraintsValidation(strategy?.constraints);
const errors = useFormErrors(); const errors = useFormErrors();
const showVariants = Boolean( const showVariants = Boolean(
addStrategy?.parameters && 'stickiness' in addStrategy?.parameters, currentStrategy?.parameters &&
'stickiness' in currentStrategy?.parameters,
); );
const stickiness = const stickiness =
addStrategy?.parameters && 'stickiness' in addStrategy?.parameters currentStrategy?.parameters &&
? String(addStrategy.parameters.stickiness) 'stickiness' in currentStrategy?.parameters
? String(currentStrategy.parameters.stickiness)
: 'default'; : 'default';
useEffect(() => { useEffect(() => {
setAddStrategy((prev) => ({ setSegments([]);
}, []);
useEffect(() => {
setCurrentStrategy((prev) => ({
...prev, ...prev,
variants: (addStrategy.variants || []).map((variant) => ({ variants: (currentStrategy.variants || []).map((variant) => ({
stickiness, stickiness,
name: variant.name, name: variant.name,
weight: variant.weight, weight: variant.weight,
@ -159,9 +167,9 @@ export const ReleasePlanTemplateAddStrategyForm = ({
weightType: variant.weightType, weightType: variant.weightType,
})), })),
})); }));
}, [stickiness, JSON.stringify(addStrategy.variants)]); }, [stickiness, JSON.stringify(currentStrategy.variants)]);
if (!strategy || !addStrategy || !strategyDefinition) { if (!strategy || !currentStrategy || !strategyDefinition) {
return null; return null;
} }
@ -170,7 +178,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
}; };
const getTargetingCount = () => { const getTargetingCount = () => {
const constraintCount = addStrategy?.constraints?.length || 0; const constraintCount = currentStrategy?.constraints?.length || 0;
const segmentCount = segments?.length || 0; const segmentCount = segments?.length || 0;
return constraintCount + segmentCount; return constraintCount + segmentCount;
@ -178,7 +186,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
const validateParameter = (key: string, value: string) => true; const validateParameter = (key: string, value: string) => true;
const updateParameter = (name: string, value: string) => { const updateParameter = (name: string, value: string) => {
setAddStrategy( setCurrentStrategy(
produce((draft) => { produce((draft) => {
if (!draft) { if (!draft) {
return; return;
@ -194,11 +202,12 @@ export const ReleasePlanTemplateAddStrategyForm = ({
); );
}; };
const addStrategyToMilestone = () => { const AddUpdateMilestoneStrategy = () => {
if (!milestoneId) { if (!milestoneId) {
return; return;
} }
onAddStrategy(milestoneId, addStrategy);
onAddUpdateStrategy(milestoneId, currentStrategy);
}; };
return ( return (
@ -208,15 +217,17 @@ export const ReleasePlanTemplateAddStrategyForm = ({
> >
<StyledHeaderBox> <StyledHeaderBox>
<StyledTitle> <StyledTitle>
{formatStrategyName(addStrategy.name || '')} {formatStrategyName(currentStrategy.strategyName || '')}
{addStrategy.name === 'flexibleRollout' && ( {currentStrategy.strategyName === 'flexibleRollout' && (
<Badge color='success' sx={{ marginLeft: '1rem' }}> <Badge color='success' sx={{ marginLeft: '1rem' }}>
{addStrategy.parameters?.rollout}% {currentStrategy.parameters?.rollout}%
</Badge> </Badge>
)} )}
</StyledTitle> </StyledTitle>
</StyledHeaderBox> </StyledHeaderBox>
{!BuiltInStrategies.includes(strategy.name || 'default') && ( {!BuiltInStrategies.includes(
strategy.strategyName || 'default',
) && (
<StyledAlertBox> <StyledAlertBox>
<Alert severity='warning'> <Alert severity='warning'>
Custom strategies are deprecated. We recommend not Custom strategies are deprecated. We recommend not
@ -251,7 +262,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
<Typography> <Typography>
Variants Variants
<StyledBadge> <StyledBadge>
{addStrategy?.variants?.length || 0} {currentStrategy?.variants?.length || 0}
</StyledBadge> </StyledBadge>
</Typography> </Typography>
} }
@ -262,16 +273,16 @@ export const ReleasePlanTemplateAddStrategyForm = ({
{activeTab === 0 && ( {activeTab === 0 && (
<> <>
<MilestoneStrategyTitle <MilestoneStrategyTitle
title={addStrategy.title || ''} title={currentStrategy.title || ''}
setTitle={(title) => setTitle={(title) =>
updateParameter('title', title) updateParameter('title', title)
} }
/> />
<MilestoneStrategyType <MilestoneStrategyType
strategy={addStrategy} strategy={currentStrategy}
strategyDefinition={strategyDefinition} strategyDefinition={strategyDefinition}
parameters={addStrategy.parameters} parameters={currentStrategy.parameters}
updateParameter={updateParameter} updateParameter={updateParameter}
errors={errors} errors={errors}
/> />
@ -291,8 +302,8 @@ export const ReleasePlanTemplateAddStrategyForm = ({
<StyledDividerContent>AND</StyledDividerContent> <StyledDividerContent>AND</StyledDividerContent>
</StyledBox> </StyledBox>
<MilestoneStrategyConstraints <MilestoneStrategyConstraints
strategy={addStrategy} strategy={currentStrategy}
setStrategy={setAddStrategy} setStrategy={setCurrentStrategy}
/> />
be evaluated for users and applications that match be evaluated for users and applications that match
the specified preconditions. the specified preconditions.
@ -301,8 +312,8 @@ export const ReleasePlanTemplateAddStrategyForm = ({
)} )}
{activeTab === 2 && showVariants && ( {activeTab === 2 && showVariants && (
<MilestoneStrategyVariants <MilestoneStrategyVariants
strategy={addStrategy} strategy={currentStrategy}
setStrategy={setAddStrategy} setStrategy={setCurrentStrategy}
/> />
)} )}
</StyledContentDiv> </StyledContentDiv>
@ -312,7 +323,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
color='primary' color='primary'
type='submit' type='submit'
disabled={!hasValidConstraints || errors.hasFormErrors()} disabled={!hasValidConstraints || errors.hasFormErrors()}
onClick={addStrategyToMilestone} onClick={AddUpdateMilestoneStrategy}
> >
Save strategy Save strategy
</Button> </Button>

View File

@ -58,7 +58,7 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
handleSubmit, handleSubmit,
children, children,
}) => { }) => {
const [addStrategyOpen, setAddStrategyOpen] = useState(false); const [addUpdateStrategyOpen, setAddUpdateStrategyOpen] = useState(false);
const [activeMilestoneId, setActiveMilestoneId] = useState< const [activeMilestoneId, setActiveMilestoneId] = useState<
string | undefined string | undefined
>(); >();
@ -71,37 +71,49 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
title: '', title: '',
id: 'temp', id: 'temp',
}); });
const openAddStrategyForm = ( const openAddUpdateStrategyForm = (
milestoneId: string, milestoneId: string,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => { ) => {
setActiveMilestoneId(milestoneId); setActiveMilestoneId(milestoneId);
setStrategy(strategy); setStrategy(strategy);
setAddStrategyOpen(true); setAddUpdateStrategyOpen(true);
}; };
const addStrategy = ( const addUpdateStrategy = (
milestoneId: string, milestoneId: string,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>, strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => { ) => {
setMilestones((prev) => const milestone = milestones.find((m) => m.id === milestoneId);
prev.map((milestone, i) => const existingStrategy = milestone?.strategies?.find(
milestone.id === milestoneId (strat) => strat.id === strategy.id,
? {
...milestone,
strategies: [
...(milestone.strategies || []),
{
...strategy,
strategyName: strategy.name,
sortOrder: milestone.strategies?.length || 0,
},
],
}
: milestone,
),
); );
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); setActiveMilestoneId(undefined);
setStrategy({ setStrategy({
name: 'flexibleRollout', name: 'flexibleRollout',
@ -112,6 +124,29 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
}); });
}; };
const milestoneChanged = (milestone: IReleasePlanMilestonePayload) => {
setMilestones((prev) =>
prev.map((mstone) =>
mstone.id === milestone.id ? { ...milestone } : mstone,
),
);
};
const milestoneStrategyChanged = (
milestone: IReleasePlanMilestonePayload,
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
const strategies = milestone.strategies || [];
milestoneChanged({
...milestone,
strategies: [
...strategies.map((strat) =>
strat.id === strategy.id ? strategy : strat,
),
],
});
};
return ( return (
<FormTemplate <FormTemplate
title={formTitle} title={formTitle}
@ -145,9 +180,10 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
<MilestoneList <MilestoneList
milestones={milestones} milestones={milestones}
setMilestones={setMilestones} setMilestones={setMilestones}
openAddStrategyForm={openAddStrategyForm} openAddStrategyForm={openAddUpdateStrategyForm}
errors={errors} errors={errors}
clearErrors={clearErrors} clearErrors={clearErrors}
milestoneChanged={milestoneChanged}
/> />
{children} {children}
@ -155,14 +191,14 @@ export const TemplateForm: React.FC<ITemplateFormProps> = ({
<SidebarModal <SidebarModal
label='Add strategy to template milestone' label='Add strategy to template milestone'
onClose={() => {}} onClose={() => {}}
open={addStrategyOpen} open={addUpdateStrategyOpen}
> >
<ReleasePlanTemplateAddStrategyForm <ReleasePlanTemplateAddStrategyForm
milestoneId={activeMilestoneId} milestoneId={activeMilestoneId}
strategy={strategy} strategy={strategy}
onAddStrategy={addStrategy} onAddUpdateStrategy={addUpdateStrategy}
onCancel={() => { onCancel={() => {
setAddStrategyOpen(false); setAddUpdateStrategyOpen(false);
}} }}
/> />
</SidebarModal> </SidebarModal>

View File

@ -50,10 +50,5 @@ export interface IReleasePlanMilestonePayload {
id: string; id: string;
name: string; name: string;
sortOrder: number; sortOrder: number;
strategies?: IReleasePlanStrategyPayload[]; strategies?: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>[];
}
export interface IReleasePlanStrategyPayload {
id?: string;
name: string;
} }